From 84067b1b691b307732bdeedf4e5e117d5a0356df Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Sat, 28 Feb 2026 15:41:22 -0600 Subject: [PATCH 1/2] Add excludeIncomplete option to getCompetitionScores When excludeIncomplete is true, users who haven't forecasted on every prop in a competition are filtered out of both overall and category scores. This supports excluding partial participants from public competition leaderboards. Defaults to false for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- lib/db_actions/competition-scores.test.ts | 87 +++++++++++++++++++++++ lib/db_actions/competition-scores.ts | 56 ++++++++++++--- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/lib/db_actions/competition-scores.test.ts b/lib/db_actions/competition-scores.test.ts index dec99a4c..15ed33c3 100644 --- a/lib/db_actions/competition-scores.test.ts +++ b/lib/db_actions/competition-scores.test.ts @@ -242,6 +242,93 @@ describe("getCompetitionScores", () => { }, ); + ifRunningContainerTestsIt( + "should exclude users who haven't forecasted all props when excludeIncomplete is true", + async () => { + const completeUser = await factory.createUser({ + name: "Complete User", + }); + const incompleteUser = await factory.createUser({ + name: "Incomplete User", + }); + vi.mocked(getUserFromCookies).mockResolvedValue(completeUser); + + const competition = await factory.createCompetition(); + + const prop1 = await factory.createCompetitionProp(competition.id, { + text: "Prop 1", + }); + const prop2 = await factory.createCompetitionProp(competition.id, { + text: "Prop 2", + }); + const prop3 = await factory.createCompetitionProp(competition.id, { + text: "Prop 3", + }); + + // Complete user forecasts on all 3 props + await factory.createForecast(completeUser.id, prop1.id, { + forecast: 0.7, + }); + await factory.createForecast(completeUser.id, prop2.id, { + forecast: 0.4, + }); + await factory.createForecast(completeUser.id, prop3.id, { + forecast: 0.6, + }); + + // Incomplete user only forecasts on 2 of 3 props + await factory.createForecast(incompleteUser.id, prop1.id, { + forecast: 0.8, + }); + await factory.createForecast(incompleteUser.id, prop2.id, { + forecast: 0.3, + }); + + // Resolve all props + await factory.createResolution(prop1.id, { + resolution: true, + notes: "resolved", + user_id: completeUser.id, + }); + await factory.createResolution(prop2.id, { + resolution: false, + notes: "resolved", + user_id: completeUser.id, + }); + await factory.createResolution(prop3.id, { + resolution: true, + notes: "resolved", + user_id: completeUser.id, + }); + + // Without excludeIncomplete: both users appear + const allResult = await getCompetitionScores({ + competitionId: competition.id, + }); + expect(allResult.success).toBe(true); + if (allResult.success) { + expect(allResult.data.overallScores).toHaveLength(2); + } + + // With excludeIncomplete: only the complete user appears + const filteredResult = await getCompetitionScores({ + competitionId: competition.id, + excludeIncomplete: true, + }); + expect(filteredResult.success).toBe(true); + if (filteredResult.success) { + expect(filteredResult.data.overallScores).toHaveLength(1); + expect(filteredResult.data.overallScores[0].userId).toBe( + completeUser.id, + ); + // Category scores should also be filtered + filteredResult.data.categoryScores.forEach((cs) => { + expect(cs.userId).toBe(completeUser.id); + }); + } + }, + ); + ifRunningContainerTestsIt( "should handle competition with no resolved forecasts", async () => { diff --git a/lib/db_actions/competition-scores.ts b/lib/db_actions/competition-scores.ts index ad3ac4d9..ac624ead 100644 --- a/lib/db_actions/competition-scores.ts +++ b/lib/db_actions/competition-scores.ts @@ -49,8 +49,10 @@ export interface UserScoreBreakdown { export async function getCompetitionScores({ competitionId, + excludeIncomplete = false, }: { competitionId: number; + excludeIncomplete?: boolean; }): Promise> { // Scores are computed as the mean of the squared differences between the forecast and the resolution. const currentUser = await getUserFromCookies(); @@ -96,7 +98,32 @@ export async function getCompetitionScores({ .execute(), ]); - return { overallResults, categoryResults }; + // When excludeIncomplete is true, filter out users who haven't + // forecasted on every prop in the competition. + let completeUserIds: Set | null = null; + if (excludeIncomplete) { + const [totalPropsResult, userForecastCounts] = await Promise.all([ + trx + .selectFrom("props") + .select(sql`COUNT(*)`.as("count")) + .where("competition_id", "=", competitionId) + .executeTakeFirstOrThrow(), + trx + .selectFrom("v_forecasts") + .select(["user_id", sql`COUNT(DISTINCT prop_id)`.as("count")]) + .where("competition_id", "=", competitionId) + .groupBy("user_id") + .execute(), + ]); + const totalProps = Number(totalPropsResult.count); + completeUserIds = new Set( + userForecastCounts + .filter((row) => Number(row.count) >= totalProps) + .map((row) => row.user_id), + ); + } + + return { overallResults, categoryResults, completeUserIds }; }); const duration = Date.now() - startTime; @@ -110,21 +137,28 @@ export async function getCompetitionScores({ categoryCount: results.categoryResults.length, }); - // Transform results into the expected array format - const overallScores: UserScore[] = results.overallResults.map((row) => ({ - userId: row.user_id, - userName: row.user_name, - score: Number(row.average_score), - })); + // Transform results into the expected array format, filtering out + // incomplete users if requested. + const { completeUserIds } = results; + const includeUser = (userId: number) => + completeUserIds === null || completeUserIds.has(userId); - const categoryScores: UserCategoryScore[] = results.categoryResults.map( - (row) => ({ + const overallScores: UserScore[] = results.overallResults + .filter((row) => includeUser(row.user_id)) + .map((row) => ({ + userId: row.user_id, + userName: row.user_name, + score: Number(row.average_score), + })); + + const categoryScores: UserCategoryScore[] = results.categoryResults + .filter((row) => includeUser(row.user_id)) + .map((row) => ({ userId: row.user_id, userName: row.user_name, categoryId: row.category_id!, score: Number(row.average_score), - }), - ); + })); logger.info("Competition scores calculated successfully", { operation: "getCompetitionScores", From 26b67ec638424066d67ad31019844b98886f9162 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Mar 2026 20:21:56 -0600 Subject: [PATCH 2/2] Surface incomplete forecasters on leaderboards Always compute which users haven't forecasted all props and return incompleteUserIds from getCompetitionScores. Mark these users with "(incomplete)" labels across the main leaderboard, sidebar leaderboard, and mini leaderboard components. Co-Authored-By: Claude Opus 4.6 --- .../leaderboard-sidebar.tsx | 7 +++ components/landing/mini-leaderboard.tsx | 8 ++- components/scores/leaderboard.tsx | 21 +++++++ lib/db_actions/competition-scores.test.ts | 16 +++++- lib/db_actions/competition-scores.ts | 57 ++++++++++--------- 5 files changed, 79 insertions(+), 30 deletions(-) diff --git a/components/competition-dashboard/leaderboard-sidebar.tsx b/components/competition-dashboard/leaderboard-sidebar.tsx index 2cf259f2..674bcdcf 100644 --- a/components/competition-dashboard/leaderboard-sidebar.tsx +++ b/components/competition-dashboard/leaderboard-sidebar.tsx @@ -10,6 +10,7 @@ interface LeaderboardEntry { userName: string; score: number; isCurrentUser: boolean; + isIncomplete: boolean; } interface LeaderboardRowProps { @@ -42,6 +43,9 @@ function LeaderboardRow({ entry }: LeaderboardRowProps) { {entry.isCurrentUser && ( (you) )} + {entry.isIncomplete && ( + (incomplete) + )}
a.score - b.score, ); + const incompleteSet = new Set(scores.incompleteUserIds); + // Build entries with ranks const entries: LeaderboardEntry[] = sortedUsers .slice(0, maxEntries) @@ -82,6 +88,7 @@ export function LeaderboardSidebar({ userName: user.userName, score: user.score, isCurrentUser: user.userId === currentUserId, + isIncomplete: incompleteSet.has(user.userId), })); if (entries.length === 0) { diff --git a/components/landing/mini-leaderboard.tsx b/components/landing/mini-leaderboard.tsx index 4f208b2b..6a163701 100644 --- a/components/landing/mini-leaderboard.tsx +++ b/components/landing/mini-leaderboard.tsx @@ -27,6 +27,7 @@ export default async function MiniLeaderboard({ } const scores = scoresResult.data; + const incompleteSet = new Set(scores.incompleteUserIds); const sortedUsers = [...scores.overallScores] .sort((a, b) => a.score - b.score) .slice(0, limit); @@ -57,7 +58,12 @@ export default async function MiniLeaderboard({ {index + 1}. - {userScore.userName} + + {userScore.userName} + {incompleteSet.has(userScore.userId) && ( + (incomplete) + )} +
{userScore.score.toFixed(2)} diff --git a/components/scores/leaderboard.tsx b/components/scores/leaderboard.tsx index 8eb68062..f4b75666 100644 --- a/components/scores/leaderboard.tsx +++ b/components/scores/leaderboard.tsx @@ -37,6 +37,7 @@ interface UserWithRank { score: number; categoryScores: UserCategoryScore[]; isCurrentUser: boolean; + isIncomplete: boolean; } interface ScoreRowProps { @@ -87,6 +88,11 @@ function ScoreRow({ (you) )} + {user.isIncomplete && ( + + (incomplete) + + )} @@ -189,6 +195,8 @@ export default function Leaderboard({ (a, b) => a.score - b.score ); + const incompleteSet = new Set(scores.incompleteUserIds); + // Build users with ranks and category scores const usersWithRanks: UserWithRank[] = sortedUsers.map((user, index) => ({ rank: index + 1, @@ -199,6 +207,7 @@ export default function Leaderboard({ (cs) => cs.userId === user.userId ), isCurrentUser: user.userId === currentUserId, + isIncomplete: incompleteSet.has(user.userId), })); // Find current user's data @@ -305,6 +314,11 @@ export default function Leaderboard({
{user.userName} + {user.isIncomplete && ( + + (incomplete) + + )}
{user.score.toFixed(3)} @@ -339,6 +353,13 @@ export default function Leaderboard({ ))}
+ + {/* Footnote for incomplete users */} + {scores.incompleteUserIds.length > 0 && ( +

+ Users marked incomplete have not forecasted all propositions. +

+ )} ); } diff --git a/lib/db_actions/competition-scores.test.ts b/lib/db_actions/competition-scores.test.ts index 15ed33c3..81f2890e 100644 --- a/lib/db_actions/competition-scores.test.ts +++ b/lib/db_actions/competition-scores.test.ts @@ -69,6 +69,7 @@ describe("getCompetitionScores", () => { if (result.success) { expect(result.data.overallScores).toEqual([]); expect(result.data.categoryScores).toEqual([]); + expect(result.data.incompleteUserIds).toEqual([]); } }, ); @@ -143,7 +144,10 @@ describe("getCompetitionScores", () => { expect(result.success).toBe(true); if (result.success) { - const { overallScores, categoryScores } = result.data; + const { overallScores, categoryScores, incompleteUserIds } = result.data; + + // All users forecasted all props, so none are incomplete + expect(incompleteUserIds).toEqual([]); // Check overall scores (both users have resolved forecasts) expect(overallScores).toHaveLength(2); @@ -231,7 +235,10 @@ describe("getCompetitionScores", () => { expect(result.success).toBe(true); if (result.success) { - const { overallScores } = result.data; + const { overallScores, incompleteUserIds } = result.data; + + // Single user forecasted the only prop, so they are complete + expect(incompleteUserIds).toEqual([]); // Brier score = (resolution - forecast)^2 = (1 - 0.8)^2 = 0.04 expect(overallScores).toHaveLength(1); @@ -301,13 +308,14 @@ describe("getCompetitionScores", () => { user_id: completeUser.id, }); - // Without excludeIncomplete: both users appear + // Without excludeIncomplete: both users appear, incomplete user is flagged const allResult = await getCompetitionScores({ competitionId: competition.id, }); expect(allResult.success).toBe(true); if (allResult.success) { expect(allResult.data.overallScores).toHaveLength(2); + expect(allResult.data.incompleteUserIds).toEqual([incompleteUser.id]); } // With excludeIncomplete: only the complete user appears @@ -350,6 +358,7 @@ describe("getCompetitionScores", () => { if (result.success) { expect(result.data.overallScores).toEqual([]); expect(result.data.categoryScores).toEqual([]); + expect(result.data.incompleteUserIds).toEqual([]); } }, ); @@ -369,6 +378,7 @@ describe("getCompetitionScores", () => { // Non-existent competition should return empty scores expect(result.data.overallScores).toEqual([]); expect(result.data.categoryScores).toEqual([]); + expect(result.data.incompleteUserIds).toEqual([]); } }, ); diff --git a/lib/db_actions/competition-scores.ts b/lib/db_actions/competition-scores.ts index ac624ead..0e32c948 100644 --- a/lib/db_actions/competition-scores.ts +++ b/lib/db_actions/competition-scores.ts @@ -26,6 +26,7 @@ export interface UserCategoryScore { export interface CompetitionScore { overallScores: UserScore[]; categoryScores: UserCategoryScore[]; + incompleteUserIds: number[]; } export interface UserForecastScore { @@ -98,32 +99,35 @@ export async function getCompetitionScores({ .execute(), ]); - // When excludeIncomplete is true, filter out users who haven't - // forecasted on every prop in the competition. - let completeUserIds: Set | null = null; - if (excludeIncomplete) { - const [totalPropsResult, userForecastCounts] = await Promise.all([ - trx - .selectFrom("props") - .select(sql`COUNT(*)`.as("count")) - .where("competition_id", "=", competitionId) - .executeTakeFirstOrThrow(), - trx - .selectFrom("v_forecasts") - .select(["user_id", sql`COUNT(DISTINCT prop_id)`.as("count")]) - .where("competition_id", "=", competitionId) - .groupBy("user_id") - .execute(), - ]); - const totalProps = Number(totalPropsResult.count); - completeUserIds = new Set( - userForecastCounts - .filter((row) => Number(row.count) >= totalProps) - .map((row) => row.user_id), - ); - } + // Compute which users have forecasted on every prop in the competition. + const [totalPropsResult, userForecastCounts] = await Promise.all([ + trx + .selectFrom("props") + .select(sql`COUNT(*)`.as("count")) + .where("competition_id", "=", competitionId) + .executeTakeFirstOrThrow(), + trx + .selectFrom("v_forecasts") + .select(["user_id", sql`COUNT(DISTINCT prop_id)`.as("count")]) + .where("competition_id", "=", competitionId) + .groupBy("user_id") + .execute(), + ]); + const totalProps = Number(totalPropsResult.count); + const completeUserIds = new Set( + userForecastCounts + .filter((row) => Number(row.count) >= totalProps) + .map((row) => row.user_id), + ); + // All user IDs that appear in scores but aren't complete + const allScoredUserIds = new Set( + overallResults.map((row) => row.user_id), + ); + const incompleteUserIds = [...allScoredUserIds].filter( + (id) => !completeUserIds.has(id), + ); - return { overallResults, categoryResults, completeUserIds }; + return { overallResults, categoryResults, completeUserIds, incompleteUserIds }; }); const duration = Date.now() - startTime; @@ -141,7 +145,7 @@ export async function getCompetitionScores({ // incomplete users if requested. const { completeUserIds } = results; const includeUser = (userId: number) => - completeUserIds === null || completeUserIds.has(userId); + !excludeIncomplete || completeUserIds.has(userId); const overallScores: UserScore[] = results.overallResults .filter((row) => includeUser(row.user_id)) @@ -172,6 +176,7 @@ export async function getCompetitionScores({ return success({ overallScores, categoryScores, + incompleteUserIds: results.incompleteUserIds, }); } catch (err) { const duration = Date.now() - startTime;