From 175cffd3b833bcd2fdc3a632ffbef0da004bca68 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 6 Feb 2026 15:49:44 +0200 Subject: [PATCH 1/2] feat: support multiple leaderboards with tabs for projects - Update API service to accept URLSearchParams and return LeaderboardDetails[] - Add LeaderboardDisplayConfig type with display_name, column_renames, display_order - Sort leaderboards by display_order in server component - Show TabBar when multiple leaderboards exist, hide for single leaderboard - Apply display_config.display_name for tab labels with fallback to name - Support column_renames for custom column header names in table --- .../components/project_leaderboard.tsx | 42 +++++++++++----- .../components/project_leaderboard_client.tsx | 48 ++++++++++++++++--- .../project_leaderboard_table/index.tsx | 38 +++++++++++---- .../components/tournament_timeline.tsx | 11 ++--- .../api/leaderboard/leaderboard.shared.ts | 6 +-- front_end/src/types/scoring.ts | 10 +++- 6 files changed, 117 insertions(+), 38 deletions(-) diff --git a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard.tsx b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard.tsx index 9294178005..ae5d21faf7 100644 --- a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard.tsx +++ b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard.tsx @@ -3,7 +3,7 @@ import { FC } from "react"; import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; import ServerLeaderboardApi from "@/services/api/leaderboard/leaderboard.server"; -import { LeaderboardType } from "@/types/scoring"; +import { LeaderboardDetails, LeaderboardType } from "@/types/scoring"; import ProjectLeaderboardClient from "./project_leaderboard_client"; @@ -14,25 +14,43 @@ type Props = { isQuestionSeries?: boolean; }; +function sortLeaderboards( + leaderboards: LeaderboardDetails[] +): LeaderboardDetails[] { + return [...leaderboards].sort((a, b) => { + const orderA = a.display_config?.display_order ?? 0; + const orderB = b.display_config?.display_order ?? 0; + return orderA - orderB; + }); +} + const ProjectLeaderboard: FC = async ({ projectId, leaderboardType, isQuestionSeries, userId, }) => { - const leaderboardDetails = ( - await ServerLeaderboardApi.getProjectLeaderboard( - projectId, - leaderboardType - ? new URLSearchParams({ score_type: leaderboardType }) - : null - ) - )?.[0]; // This grabs only the first serialized leaderboard, requires work! - - if (!leaderboardDetails || !leaderboardDetails.entries.length) { + const params = leaderboardType + ? new URLSearchParams({ score_type: leaderboardType }) + : null; + const leaderboards = await ServerLeaderboardApi.getProjectLeaderboard( + projectId, + params + ); + + if (!leaderboards || leaderboards.length === 0) { + return null; + } + + const leaderboardsWithEntries = leaderboards.filter( + (lb) => lb.entries.length > 0 + ); + if (leaderboardsWithEntries.length === 0) { return null; } + const sortedLeaderboards = sortLeaderboards(leaderboardsWithEntries); + const t = await getTranslations(); const leaderboardTitle = isQuestionSeries @@ -41,7 +59,7 @@ const ProjectLeaderboard: FC = async ({ return ( ( + leaderboards[0]?.id ?? 0 + ); + + const activeLeaderboard = + leaderboards.find((lb) => lb.id === activeLeaderboardId) ?? leaderboards[0]; + + if (!activeLeaderboard) { + return null; + } + + const hasMultipleLeaderboards = leaderboards.length > 1; const advancedToggleElement = (
@@ -41,11 +63,16 @@ const ProjectLeaderboardClient = ({
); - const scoreType = leaderboardDetails.score_type; + const scoreType = activeLeaderboard.score_type; const isPeer = scoreType === "peer_tournament"; const isSpotPeer = scoreType === "spot_peer_tournament"; const showExplainer = isPeer || isSpotPeer; + const tabOptions = leaderboards.map((lb) => ({ + value: lb.id, + label: getLeaderboardDisplayName(lb, t("leaderboard")), + })); + return ( - {!!leaderboardDetails.prize_pool && ( + {hasMultipleLeaderboards && ( +
+ +
+ )} + {!!activeLeaderboard.prize_pool && (
{t("prizePool") + ": "} - ${leaderboardDetails.prize_pool.toLocaleString()} + ${activeLeaderboard.prize_pool.toLocaleString()}
)} diff --git a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx index 18aa68fb0b..64ceae73ef 100644 --- a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx +++ b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx @@ -1,10 +1,10 @@ "use client"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import { FC, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; import Button from "@/components/ui/button"; -import { LeaderboardDetails } from "@/types/scoring"; +import { LeaderboardDetails, LeaderboardDisplayConfig } from "@/types/scoring"; import TableHeader from "./table_header"; import TableRow from "./table_row"; @@ -25,6 +25,22 @@ const ProjectLeaderboardTable: FC = ({ }) => { const t = useTranslations(); + const columnRenames = leaderboardDetails.display_config?.column_renames; + + const getColumnName = useCallback( + ( + translationKey: Parameters[0], + columnRenames?: LeaderboardDisplayConfig["column_renames"] + ): string => { + const defaultName = t(translationKey); + if (!columnRenames) { + return defaultName; + } + return columnRenames[defaultName] ?? defaultName; + }, + [t] + ); + const [step, setStep] = useState(paginationStep); const leaderboardEntries = useMemo(() => { return isNil(step) @@ -50,19 +66,21 @@ const ProjectLeaderboardTable: FC = ({ - {t("rank")} + {getColumnName("rank", columnRenames)} - {t("forecaster")} + {getColumnName("forecaster", columnRenames)} + + + {getColumnName("totalScore", columnRenames)} - {t("totalScore")} {isAdvanced && ( <> - {t("questions")} + {getColumnName("questions", columnRenames)} - {t("coverage")} + {getColumnName("coverage", columnRenames)} )} @@ -71,16 +89,16 @@ const ProjectLeaderboardTable: FC = ({ {isAdvanced && ( <> - {t("take")} + {getColumnName("take", columnRenames)} - {t("percentPrize")} + {getColumnName("percentPrize", columnRenames)} )} {leaderboardDetails.finalized ? ( - t("prize") + getColumnName("prize", columnRenames) ) : ( )} diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_timeline.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_timeline.tsx index e4e7cd869d..322cbd4837 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_timeline.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_timeline.tsx @@ -20,12 +20,11 @@ const TournamentTimeline: FC = async ({ tournament }) => { let leaderboardDetails: LeaderboardDetails | undefined = undefined; try { - leaderboardDetails = ( - await ServerLeaderboardApi.getProjectLeaderboard( - tournament.id, - new URLSearchParams({ primary_only: "true", with_entries: "false" }) - ) - )?.[0]; + const leaderboards = await ServerLeaderboardApi.getProjectLeaderboard( + tournament.id, + new URLSearchParams({ primary_only: "true", with_entries: "false" }) + ); + leaderboardDetails = leaderboards?.[0]; } catch (error) { logError(error); } diff --git a/front_end/src/services/api/leaderboard/leaderboard.shared.ts b/front_end/src/services/api/leaderboard/leaderboard.shared.ts index e90abcc034..9c28e3927b 100644 --- a/front_end/src/services/api/leaderboard/leaderboard.shared.ts +++ b/front_end/src/services/api/leaderboard/leaderboard.shared.ts @@ -54,11 +54,11 @@ class LeaderboardApi extends ApiService { async getProjectLeaderboard( projectId: number, - endpointParams: URLSearchParams | null = null + params?: URLSearchParams | null ): Promise { // TODO: make paginated - const params = endpointParams ?? new URLSearchParams(); - const url = `/leaderboards/project/${projectId}/${params.toString() ? `?${params.toString()}` : ""}`; + const searchParams = params ?? new URLSearchParams(); + const url = `/leaderboards/project/${projectId}/${searchParams.toString() ? `?${searchParams.toString()}` : ""}`; return await this.get(url); } diff --git a/front_end/src/types/scoring.ts b/front_end/src/types/scoring.ts index 4aad124540..6cbac784fd 100644 --- a/front_end/src/types/scoring.ts +++ b/front_end/src/types/scoring.ts @@ -101,7 +101,15 @@ export type MedalRanksEntry = { | "questions_global"; }; +export type LeaderboardDisplayConfig = { + display_name?: string; + column_renames?: Record; + display_order?: number; + display_on_project?: boolean; +}; + type BaseLeaderboardDetails = { + id: number; project_id: number; project_type: MedalProjectType; project_name: string; @@ -114,7 +122,7 @@ type BaseLeaderboardDetails = { finalized: boolean; prize_pool: number | null; max_coverage?: number; - display_config: Record | null; + display_config: LeaderboardDisplayConfig | null; }; export type LeaderboardDetails = BaseLeaderboardDetails & { From 2765a2eed54d9d59867040b0cfd09e679e7975e8 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 9 Feb 2026 10:40:51 +0200 Subject: [PATCH 2/2] fix: column renames now work for non-English locales --- .../components/project_leaderboard_table/index.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx index 64ceae73ef..16fbd6abd7 100644 --- a/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx +++ b/front_end/src/app/(main)/(leaderboards)/leaderboard/components/project_leaderboard_table/index.tsx @@ -3,6 +3,7 @@ import { isNil } from "lodash"; import { useTranslations } from "next-intl"; import { FC, useCallback, useMemo, useState } from "react"; +import enMessages from "@/../../messages/en.json"; import Button from "@/components/ui/button"; import { LeaderboardDetails, LeaderboardDisplayConfig } from "@/types/scoring"; @@ -32,11 +33,17 @@ const ProjectLeaderboardTable: FC = ({ translationKey: Parameters[0], columnRenames?: LeaderboardDisplayConfig["column_renames"] ): string => { - const defaultName = t(translationKey); + const localizedName = t(translationKey); if (!columnRenames) { - return defaultName; + return localizedName; } - return columnRenames[defaultName] ?? defaultName; + const englishName = (enMessages as Record)[ + String(translationKey) + ]; + if (typeof englishName === "string" && columnRenames[englishName]) { + return columnRenames[englishName]; + } + return localizedName; }, [t] );