From 85bbd9767d87c7095852f6fe8448b691bb2a7d97 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sun, 31 May 2026 20:19:30 -0700 Subject: [PATCH] fix(leaderboard): use competition ranks for all-time ties Validation * Validation tier: Tier 2 - Narrow Runtime Change, focused frontend leaderboard/embed rank semantics. * bun install --frozen-lockfile: PASS * bun x vitest run __tests__/lib/getLeaderboardAllTime.test.ts __tests__/lib/getUserEmbedStats.test.ts: PASS, 10 tests * bun x vitest run __tests__/lib/getLeaderboardAllTime.test.ts __tests__/lib/getLeaderboard.test.ts __tests__/lib/getUserEmbedStats.test.ts __tests__/api/usersProfile.test.ts: PASS, 22 tests * bun x eslint src/lib/leaderboard/getLeaderboard.ts src/lib/embed/getUserEmbedStats.ts __tests__/lib/getLeaderboardAllTime.test.ts __tests__/lib/getUserEmbedStats.test.ts: PASS * git diff --check: PASS * git diff --cached --check: PASS * Ledger: not applicable - not required for selected validation tier/change family. * Version: not applicable - not required for selected validation tier/change family. * Not run: full frontend test suite - not required for selected validation tier. Rollback * git revert HEAD --- .../lib/getLeaderboardAllTime.test.ts | 156 ++++++++++++++++++ .../__tests__/lib/getUserEmbedStats.test.ts | 47 ++++++ .../src/lib/embed/getUserEmbedStats.ts | 4 +- .../src/lib/leaderboard/getLeaderboard.ts | 32 +++- 4 files changed, 230 insertions(+), 9 deletions(-) diff --git a/packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts b/packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts index 73ee2476e..003e5bddc 100644 --- a/packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts +++ b/packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts @@ -170,6 +170,162 @@ afterEach(() => { }); describe("all-time leaderboard freshness queries", () => { + it("uses competition-rank SQL for all-time list and search ranks", async () => { + mockState.pushAwaitedResult([]); + mockState.pushAwaitedResult([{ totalTokens: 0, totalCost: 0, totalSubmissions: 0, uniqueUsers: 0 }]); + + await getLeaderboardData("all", 1, 50, "tokens"); + const listSqlTexts = serializeSqlCalls(); + + expect(listSqlTexts.some((text) => text.includes("RANK() OVER (ORDER BY"))).toBe(true); + expect(listSqlTexts.some((text) => text.includes("ROW_NUMBER() OVER"))).toBe(false); + + mockState.reset(); + mockState.pushAwaitedResult([]); + mockState.pushAwaitedResult([{ count: 0 }]); + mockState.pushAwaitedResult([{ totalTokens: 0, totalCost: 0, totalSubmissions: 0, uniqueUsers: 0 }]); + + await getLeaderboardData("all", 1, 50, "tokens", "ali"); + const searchSqlTexts = serializeSqlCalls(); + + expect(searchSqlTexts.some((text) => text.includes("RANK() OVER (ORDER BY"))).toBe(true); + expect(searchSqlTexts.some((text) => text.includes("ROW_NUMBER() OVER"))).toBe(false); + }); + + it("keeps tied all-time users at the same rank across list, search, and user rank", async () => { + mockState.pushAwaitedResult([ + { + rank: 1, + userId: "user-bob", + username: "bob", + displayName: "Bob", + avatarUrl: null, + totalTokens: 5000, + totalCost: 50, + totalActiveTimeMs: 500, + submissionCount: 1, + lastSubmission: "2026-03-12T10:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + { + rank: 2, + userId: "user-alice", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3000, + totalCost: 40, + totalActiveTimeMs: 400, + submissionCount: 1, + lastSubmission: "2026-03-12T09:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + { + rank: 2, + userId: "user-alicia", + username: "alicia", + displayName: "Alicia", + avatarUrl: null, + totalTokens: 3000, + totalCost: 30, + totalActiveTimeMs: 300, + submissionCount: 1, + lastSubmission: "2026-03-12T08:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + ]); + mockState.pushAwaitedResult([ + { + totalTokens: 11000, + totalCost: 120, + totalSubmissions: 3, + uniqueUsers: 3, + }, + ]); + + const leaderboard = await getLeaderboardData("all", 1, 50, "tokens"); + const aliceListRank = leaderboard.users.find((user) => user.username === "alice")?.rank; + const aliciaListRank = leaderboard.users.find((user) => user.username === "alicia")?.rank; + + mockState.reset(); + mockState.pushAwaitedResult([ + { + rank: 2, + userId: "user-alice", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3000, + totalCost: 40, + totalActiveTimeMs: 400, + submissionCount: 1, + lastSubmission: "2026-03-12T09:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + { + rank: 2, + userId: "user-alicia", + username: "alicia", + displayName: "Alicia", + avatarUrl: null, + totalTokens: 3000, + totalCost: 30, + totalActiveTimeMs: 300, + submissionCount: 1, + lastSubmission: "2026-03-12T08:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + ]); + mockState.pushAwaitedResult([{ count: 2 }]); + mockState.pushAwaitedResult([ + { + totalTokens: 11000, + totalCost: 120, + totalSubmissions: 3, + uniqueUsers: 3, + }, + ]); + + const searchLeaderboard = await getLeaderboardData("all", 1, 50, "tokens", "ali"); + const aliceSearchRank = searchLeaderboard.users.find((user) => user.username === "alice")?.rank; + const aliciaSearchRank = searchLeaderboard.users.find((user) => user.username === "alicia")?.rank; + + mockState.reset(); + mockState.pushAwaitedResult([ + { + id: "user-alice", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + ]); + mockState.pushAwaitedResult([ + { + totalTokens: 3000, + totalCost: 40, + totalActiveTimeMs: 400, + submissionCount: 1, + lastSubmission: "2026-03-12T09:00:00.000Z", + cliVersion: "1.9.0", + schemaVersion: 1, + }, + ]); + mockState.pushAwaitedResult([{ count: 1 }]); + + const aliceUserRank = await getUserRank("alice", "all", "tokens"); + + expect(aliceListRank).toBe(2); + expect(aliciaListRank).toBe(2); + expect(aliceSearchRank).toBe(2); + expect(aliciaSearchRank).toBe(2); + expect(aliceUserRank?.rank).toBe(2); + }); + it("uses latest-row scalar subqueries instead of MAX(cliVersion/schemaVersion)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-12T18:45:00Z")); diff --git a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts index 124eff6b6..287611b65 100644 --- a/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts +++ b/packages/frontend/__tests__/lib/getUserEmbedStats.test.ts @@ -157,6 +157,53 @@ beforeEach(() => { }); describe("user embed data", () => { + it("keeps embed tie-breakers out of the rank window", async () => { + mockState.pushAwaitedResult([ + { + id: "user-alice", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3000, + totalCost: 40, + submissionCount: 1, + updatedAt: new Date("2026-03-12T09:00:00.000Z"), + }, + ]); + mockState.pushExecuteResult([{ rank: 2, total: 3 }]); + + await getUserEmbedStats("alice", "tokens"); + const tokenSqlTexts = serializeSqlCalls(); + + expect(tokenSqlTexts.some((text) => text.includes("RANK() OVER"))).toBe(true); + expect(tokenSqlTexts.some((text) => + text.includes("total_tokens DESC, CAST(total_cost AS DECIMAL(12,4)) DESC") + )).toBe(false); + + mockState.reset(); + mockState.pushAwaitedResult([ + { + id: "user-alice", + username: "alice", + displayName: "Alice", + avatarUrl: null, + totalTokens: 3000, + totalCost: 40, + submissionCount: 1, + updatedAt: new Date("2026-03-12T09:00:00.000Z"), + }, + ]); + mockState.pushExecuteResult([{ rank: 2, total: 3 }]); + + await getUserEmbedStats("alice", "cost"); + const costSqlTexts = serializeSqlCalls(); + + expect(costSqlTexts.some((text) => text.includes("RANK() OVER"))).toBe(true); + expect(costSqlTexts.some((text) => + text.includes("CAST(total_cost AS DECIMAL(12,4)) DESC, total_tokens DESC") + )).toBe(false); + }); + it("looks up embed stats usernames case-insensitively and returns the canonical username", async () => { mockState.pushAwaitedResult([ { diff --git a/packages/frontend/src/lib/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index ea3394719..de60fb308 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -70,8 +70,8 @@ async function fetchUserEmbedStats(username: string, sortBy: EmbedSortBy): Promi RANK() OVER ( ORDER BY ${sortBy === "cost" - ? sql`CAST(total_cost AS DECIMAL(12,4)) DESC, total_tokens DESC` - : sql`total_tokens DESC, CAST(total_cost AS DECIMAL(12,4)) DESC`} + ? sql`CAST(total_cost AS DECIMAL(12,4)) DESC` + : sql`total_tokens DESC`} ) AS rank FROM submissions ) diff --git a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts index 12babc8e3..234e2fb9a 100644 --- a/packages/frontend/src/lib/leaderboard/getLeaderboard.ts +++ b/packages/frontend/src/lib/leaderboard/getLeaderboard.ts @@ -347,13 +347,18 @@ async function fetchLeaderboardData( : sortBy === "time" ? sql`COALESCE(SUM(${submissions.totalActiveTimeMs}), 0)` : sql`SUM(${submissions.totalTokens})`; + const secondaryOrderByColumn = sortBy === "cost" + ? sql`SUM(${submissions.totalTokens})` + : sortBy === "time" + ? sql`SUM(${submissions.totalTokens})` + : sql`SUM(CAST(${submissions.totalCost} AS DECIMAL(12,4)))`; if (search) { // When searching, use a subquery to compute global ranks for ALL users, // then filter by username. This preserves each user's true rank. const rankedSubquery = db .select({ - rank: sql`ROW_NUMBER() OVER (ORDER BY ${orderByColumn} DESC)`.as("rank"), + rank: sql`RANK() OVER (ORDER BY ${orderByColumn} DESC)`.as("rank"), userId: users.id, username: users.username, displayName: users.displayName, @@ -378,6 +383,11 @@ async function fetchLeaderboardData( .innerJoin(users, eq(submissions.userId, users.id)) .groupBy(users.id, users.username, users.displayName, users.avatarUrl) .as("ranked"); + const rankedSecondaryOrderByColumn = sortBy === "cost" + ? rankedSubquery.totalTokens + : sortBy === "time" + ? rankedSubquery.totalTokens + : rankedSubquery.totalCost; const escapedSearch = search.toLowerCase().replace(/[%_\\]/g, "\\$&"); const searchPattern = `%${escapedSearch}%`; @@ -385,7 +395,11 @@ async function fetchLeaderboardData( .select() .from(rankedSubquery) .where(sql`(LOWER(${rankedSubquery.username}) LIKE ${searchPattern} OR LOWER(COALESCE(${rankedSubquery.displayName}, '')) LIKE ${searchPattern})`) - .orderBy(sql`${rankedSubquery.rank} ASC`) + .orderBy( + sql`${rankedSubquery.rank} ASC`, + sql`${rankedSecondaryOrderByColumn} DESC`, + sql`LOWER(${rankedSubquery.username}) ASC` + ) .limit(limit) .offset(offset); @@ -446,10 +460,10 @@ async function fetchLeaderboardData( }; } - // Non-search path: original query with sequential rank + // Non-search path: competition rank with deterministic row ordering for ties. const leaderboardQuery = db .select({ - rank: sql`ROW_NUMBER() OVER (ORDER BY ${orderByColumn} DESC)`.as("rank"), + rank: sql`RANK() OVER (ORDER BY ${orderByColumn} DESC)`.as("rank"), userId: users.id, username: users.username, displayName: users.displayName, @@ -473,7 +487,11 @@ async function fetchLeaderboardData( .from(submissions) .innerJoin(users, eq(submissions.userId, users.id)) .groupBy(users.id, users.username, users.displayName, users.avatarUrl) - .orderBy(desc(orderByColumn)) + .orderBy( + desc(orderByColumn), + desc(secondaryOrderByColumn), + sql`LOWER(${users.username}) ASC` + ) .limit(limit) .offset(offset); @@ -493,8 +511,8 @@ async function fetchLeaderboardData( const totalPages = Math.ceil(totalUsers / limit); return { - users: (results as AllTimeLeaderboardDbRow[]).map((row, index) => ({ - rank: offset + index + 1, + users: (results as RankedLeaderboardDbRow[]).map((row) => ({ + rank: Number(row.rank), userId: row.userId, username: row.username, displayName: row.displayName,