diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index fa04b169..832d403b 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -55,6 +55,7 @@ export interface NewsPost { export interface LeaderboardSummary { id: number; name: string; + visibility?: string; deadline: string; gpu_types: string[]; priority_gpu_type: string; diff --git a/frontend/src/pages/home/Home.test.tsx b/frontend/src/pages/home/Home.test.tsx index 5b5a15f4..789ad156 100644 --- a/frontend/src/pages/home/Home.test.tsx +++ b/frontend/src/pages/home/Home.test.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@mui/material"; import { appTheme } from "../../components/common/styles/theme"; import Home from "./Home"; import * as apiHook from "../../lib/hooks/useApi"; +import * as dateUtils from "../../lib/date/utils"; import { vi, expect, it, describe, beforeEach } from "vitest"; // Mock the API hook @@ -66,7 +67,6 @@ describe("Home", () => { // Page structure is visible during loading expect(screen.getByText("Leaderboards")).toBeInTheDocument(); - expect(screen.getByText("Submit your first kernel")).toBeInTheDocument(); // Loading indicator is present expect(screen.getByRole("progressbar")).toBeInTheDocument(); }); @@ -311,6 +311,116 @@ describe("Home", () => { expect(screen.getByText("L4")).toBeInTheDocument(); }); + it("shows active private competitions in their own section before closed competitions", () => { + vi.mocked(dateUtils.isExpired).mockImplementation((deadline: string | Date) => { + if (deadline instanceof Date) return deadline.getTime() < Date.now(); + return deadline === "2024-01-01T00:00:00Z"; + }); + + const mockData = { + leaderboards: [ + { + id: 1, + name: "public-competition", + visibility: "public", + deadline: "2025-12-31T23:59:59Z", + gpu_types: ["T4"], + priority_gpu_type: "T4", + top_users: null, + }, + { + id: 2, + name: "private-competition", + visibility: "closed", + deadline: "2025-12-31T23:59:59Z", + gpu_types: ["A100"], + priority_gpu_type: "A100", + top_users: null, + }, + { + id: 3, + name: "expired-public-competition", + visibility: "public", + deadline: "2024-01-01T00:00:00Z", + gpu_types: ["L4"], + priority_gpu_type: "L4", + top_users: null, + }, + ], + now: "2025-01-01T00:00:00Z", + }; + + const mockHookReturn = { + data: mockData, + loading: false, + hasLoaded: true, + error: null, + errorStatus: null, + call: mockCall, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( + mockHookReturn, + ); + + renderWithProviders(); + + expect(screen.getByText("Active Competitions")).toBeInTheDocument(); + expect(screen.getByText("Private Competitions")).toBeInTheDocument(); + expect(screen.getByText("Closed Competitions")).toBeInTheDocument(); + expect(screen.getByText("private-competition")).toBeInTheDocument(); + + const privateHeading = screen.getByText("Private Competitions"); + const closedHeading = screen.getByText("Closed Competitions"); + expect( + Boolean( + privateHeading.compareDocumentPosition(closedHeading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + ).toBe(true); + }); + + it("keeps expired private competitions in the closed competitions section", () => { + vi.mocked(dateUtils.isExpired).mockImplementation((deadline: string | Date) => { + if (deadline instanceof Date) return deadline.getTime() < Date.now(); + return deadline === "2024-01-01T00:00:00Z"; + }); + + const mockData = { + leaderboards: [ + { + id: 1, + name: "expired-private-competition", + visibility: "closed", + deadline: "2024-01-01T00:00:00Z", + gpu_types: ["H100"], + priority_gpu_type: "H100", + top_users: null, + }, + ], + now: "2025-01-01T00:00:00Z", + }; + + const mockHookReturn = { + data: mockData, + loading: false, + hasLoaded: true, + error: null, + errorStatus: null, + call: mockCall, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( + mockHookReturn, + ); + + renderWithProviders(); + + expect(screen.queryByText("Private Competitions")).not.toBeInTheDocument(); + expect(screen.getByText("Closed Competitions")).toBeInTheDocument(); + expect(screen.getByText("expired-private-competition")).toBeInTheDocument(); + }); + describe("LeaderboardTile functionality", () => { it("displays time left correctly", () => { const mockData = { diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index c0179a4a..923a790b 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -36,6 +36,7 @@ interface TopUser { interface LeaderboardData { id: number; name: string; + visibility?: string; deadline: string; gpu_types: string[]; priority_gpu_type: string; @@ -76,12 +77,23 @@ export default function Home() { const activeLeaderboards = leaderboards.filter( (lb) => !isExpired(lb.deadline) ); + const isPrivateCompetition = (lb: LeaderboardData) => + lb.visibility === "closed"; const activeCompetitions = leaderboards.filter( - (lb) => !isExpired(lb.deadline) && !isBeginnerProblem(lb.name) + (lb) => + !isExpired(lb.deadline) && + !isBeginnerProblem(lb.name) && + !isPrivateCompetition(lb) ); const beginnerProblems = leaderboards.filter( - (lb) => !isExpired(lb.deadline) && isBeginnerProblem(lb.name) + (lb) => + !isExpired(lb.deadline) && + isBeginnerProblem(lb.name) && + !isPrivateCompetition(lb) + ); + const privateCompetitions = leaderboards.filter( + (lb) => !isExpired(lb.deadline) && isPrivateCompetition(lb) ); const closedCompetitions = leaderboards.filter((lb) => isExpired(lb.deadline) @@ -277,6 +289,25 @@ export default function Home() { )} + {/* Private Competitions */} + {privateCompetitions.length > 0 && ( + + + Private Competitions + + + Invite-only competitions with dedicated leaderboard pages. + + + {privateCompetitions.map((leaderboard) => ( + + + + ))} + + + )} + {/* Closed Competitions */} {closedCompetitions.length > 0 && ( diff --git a/kernelboard/api/leaderboard_summaries.py b/kernelboard/api/leaderboard_summaries.py index 51227a0d..f0a2f82f 100644 --- a/kernelboard/api/leaderboard_summaries.py +++ b/kernelboard/api/leaderboard_summaries.py @@ -311,6 +311,7 @@ def _get_leaderboard_metadata_query(): jsonb_build_object( 'id', l.id, 'name', l.name, + 'visibility', l.visibility, 'deadline', l.deadline, 'gpu_types', COALESCE(g.gpu_types, '[]'::jsonb), 'priority_gpu_type', p.gpu_type @@ -508,6 +509,7 @@ def _get_query(): SELECT jsonb_build_object( 'id', l.id, 'name', l.name, + 'visibility', l.visibility, 'deadline', l.deadline, 'gpu_types', COALESCE(g.gpu_types, '[]'::jsonb), 'priority_gpu_type', p.gpu_type,