From 2cc6317c62468eea74382e0805a34e15be9043ca Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 17 Aug 2026 07:42:57 +0300
Subject: [PATCH 1/7] PM-5370 - campus app setup
---
src/apps/campus/index.ts | 1 +
src/apps/campus/src/CampusApp.tsx | 20 ++++++++++++++
src/apps/campus/src/campus.routes.tsx | 33 +++++++++++++++++++++++
src/apps/campus/src/home.tsx | 10 +++++++
src/apps/campus/src/index.ts | 1 +
src/apps/platform/src/platform.routes.tsx | 2 ++
src/config/constants.ts | 2 ++
7 files changed, 69 insertions(+)
create mode 100644 src/apps/campus/index.ts
create mode 100644 src/apps/campus/src/CampusApp.tsx
create mode 100644 src/apps/campus/src/campus.routes.tsx
create mode 100644 src/apps/campus/src/home.tsx
create mode 100644 src/apps/campus/src/index.ts
diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts
new file mode 100644
index 000000000..6f39cd49b
--- /dev/null
+++ b/src/apps/campus/index.ts
@@ -0,0 +1 @@
+export * from './src'
diff --git a/src/apps/campus/src/CampusApp.tsx b/src/apps/campus/src/CampusApp.tsx
new file mode 100644
index 000000000..931de6a3f
--- /dev/null
+++ b/src/apps/campus/src/CampusApp.tsx
@@ -0,0 +1,20 @@
+import { FC, useContext, useMemo } from 'react'
+import { Outlet, Routes } from 'react-router-dom'
+
+import { routerContext, RouterContextData } from '~/libs/core'
+
+import { toolTitle } from './campus.routes'
+
+const CampusApp: FC = () => {
+ const { getChildRoutes }: RouterContextData = useContext(routerContext)
+ const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes])
+
+ return (
+ <>
+
+ {childRoutes}
+ >
+ )
+}
+
+export default CampusApp
diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx
new file mode 100644
index 000000000..0f891d648
--- /dev/null
+++ b/src/apps/campus/src/campus.routes.tsx
@@ -0,0 +1,33 @@
+import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
+import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config'
+
+const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp'))
+const CampusHomePage: LazyLoadedComponent = lazyLoad(
+ () => import('./home'),
+ 'CampusHomePage',
+)
+
+export const rootRoute: string = (
+ EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}`
+)
+
+export const toolTitle: string = ToolTitle.campus
+
+export const campusRoutes: ReadonlyArray = [
+ {
+ authRequired: true,
+ children: [
+ {
+ children: [],
+ element: ,
+ id: 'Campus Home',
+ route: '',
+ },
+ ],
+ domain: AppSubdomain.campus,
+ element: ,
+ id: toolTitle,
+ route: rootRoute,
+ title: toolTitle,
+ },
+]
diff --git a/src/apps/campus/src/home.tsx b/src/apps/campus/src/home.tsx
new file mode 100644
index 000000000..93568a303
--- /dev/null
+++ b/src/apps/campus/src/home.tsx
@@ -0,0 +1,10 @@
+import { FC } from 'react'
+
+const CampusHomePage: FC = () => (
+
+
Campus
+
Welcome to the Campus application.
+
+)
+
+export default CampusHomePage
diff --git a/src/apps/campus/src/index.ts b/src/apps/campus/src/index.ts
new file mode 100644
index 000000000..903dee652
--- /dev/null
+++ b/src/apps/campus/src/index.ts
@@ -0,0 +1 @@
+export { campusRoutes } from './campus.routes'
diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx
index 87fc44c02..a39f1f82e 100644
--- a/src/apps/platform/src/platform.routes.tsx
+++ b/src/apps/platform/src/platform.routes.tsx
@@ -2,6 +2,7 @@
import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
import { learnRoutes } from '~/apps/learn'
import { devCenterRoutes } from '~/apps/dev-center'
+import { campusRoutes } from '~/apps/campus'
import { profilesRoutes } from '~/apps/profiles'
import { accountsRoutes } from '~/apps/accounts'
import { onboardingRoutes } from '~/apps/onboarding'
@@ -38,6 +39,7 @@ export const platformRoutes: Array = [
// that matches the current path
...onboardingRoutes,
...devCenterRoutes,
+ ...campusRoutes,
...copilotsRoutes,
...learnRoutes,
...profilesRoutes,
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 62a460111..2c4f94c4e 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -9,6 +9,7 @@ export enum AppSubdomain {
wallet = 'wallet',
walletAdmin = 'wallet-admin',
copilots = 'copilots',
+ campus = 'campus',
admin = 'system-admin',
review = 'review',
calendar = 'calendar',
@@ -32,6 +33,7 @@ export enum ToolTitle {
wallet = 'Wallet',
walletAdmin = 'Wallet Admin',
copilots = 'Copilots',
+ campus = 'Campus',
admin = 'Admin',
review = 'Review',
calendar = 'Calendar',
From e39b1e640cb0d2dd993c19dc87a3739e5d1b92d6 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 18 Aug 2026 09:29:12 +0300
Subject: [PATCH 2/7] PM-5370 - campus leaderboard page
---
src/apps/campus/index.ts | 2 +-
src/apps/campus/src/campus.routes.tsx | 11 +
src/apps/campus/src/lib/hooks/index.ts | 1 +
.../src/lib/hooks/use-campus-leaderboard.ts | 38 +++
.../lib/models/campus-leaderboard.model.ts | 63 ++++
src/apps/campus/src/lib/models/index.ts | 1 +
.../services/campus-leaderboard.service.ts | 32 ++
src/apps/campus/src/lib/services/index.ts | 1 +
.../CampusLeaderboardPage.module.scss | 172 ++++++++++
.../CampusLeaderboardPage.spec.tsx | 222 +++++++++++++
.../leaderboard/CampusLeaderboardPage.tsx | 301 ++++++++++++++++++
.../ParticipationHistoryModal.module.scss | 23 ++
.../leaderboard/ParticipationHistoryModal.tsx | 124 ++++++++
.../leaderboard/RankingRulesModal.module.scss | 14 +
.../pages/leaderboard/RankingRulesModal.tsx | 43 +++
.../campus/src/pages/leaderboard/index.ts | 3 +
tsconfig.paths.json | 3 +
17 files changed, 1053 insertions(+), 1 deletion(-)
create mode 100644 src/apps/campus/src/lib/hooks/index.ts
create mode 100644 src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts
create mode 100644 src/apps/campus/src/lib/models/campus-leaderboard.model.ts
create mode 100644 src/apps/campus/src/lib/models/index.ts
create mode 100644 src/apps/campus/src/lib/services/campus-leaderboard.service.ts
create mode 100644 src/apps/campus/src/lib/services/index.ts
create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
create mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss
create mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx
create mode 100644 src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss
create mode 100644 src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx
create mode 100644 src/apps/campus/src/pages/leaderboard/index.ts
diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts
index 6f39cd49b..3c2923024 100644
--- a/src/apps/campus/index.ts
+++ b/src/apps/campus/index.ts
@@ -1 +1 @@
-export * from './src'
+export { campusRoutes } from './src'
diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx
index 0f891d648..61efd6028 100644
--- a/src/apps/campus/src/campus.routes.tsx
+++ b/src/apps/campus/src/campus.routes.tsx
@@ -6,6 +6,10 @@ const CampusHomePage: LazyLoadedComponent = lazyLoad(
() => import('./home'),
'CampusHomePage',
)
+const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad(
+ () => import('./pages/leaderboard'),
+ 'CampusLeaderboardPage',
+)
export const rootRoute: string = (
EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}`
@@ -23,6 +27,13 @@ export const campusRoutes: ReadonlyArray = [
id: 'Campus Home',
route: '',
},
+ {
+ // Campus program leaderboard, eg. https://campus.topcoder-dev.com/mecw
+ children: [],
+ element: ,
+ id: 'Campus Leaderboard',
+ route: ':groupName',
+ },
],
domain: AppSubdomain.campus,
element: ,
diff --git a/src/apps/campus/src/lib/hooks/index.ts b/src/apps/campus/src/lib/hooks/index.ts
new file mode 100644
index 000000000..55f29ec92
--- /dev/null
+++ b/src/apps/campus/src/lib/hooks/index.ts
@@ -0,0 +1 @@
+export * from './use-campus-leaderboard'
diff --git a/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts
new file mode 100644
index 000000000..7a4b1a954
--- /dev/null
+++ b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts
@@ -0,0 +1,38 @@
+import useSWR, { SWRResponse } from 'swr'
+
+import { CampusChallengeFilter, CampusLeaderboard } from '../models'
+import { campusLeaderboardUrl, fetchCampusLeaderboard } from '../services'
+
+export interface CampusLeaderboardResource {
+ data?: CampusLeaderboard
+ error?: Error & { response?: { status?: number } }
+ isLoading: boolean
+}
+
+/**
+ * Loads the campus leaderboard for a group, re-fetching when the filter changes.
+ *
+ * @param groupName group name from the route, when available.
+ * @param challengeFilter selected challenge visibility filter.
+ * @returns leaderboard resource state.
+ */
+export function useCampusLeaderboard(
+ groupName: string | undefined,
+ challengeFilter: CampusChallengeFilter,
+): CampusLeaderboardResource {
+ const url: string | undefined = groupName
+ ? campusLeaderboardUrl(groupName, challengeFilter)
+ : undefined
+
+ const { data, error }: SWRResponse = useSWR(
+ url,
+ fetchCampusLeaderboard,
+ { revalidateOnFocus: false },
+ )
+
+ return {
+ data,
+ error,
+ isLoading: !!url && !data && !error,
+ }
+}
diff --git a/src/apps/campus/src/lib/models/campus-leaderboard.model.ts b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts
new file mode 100644
index 000000000..b450c0379
--- /dev/null
+++ b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts
@@ -0,0 +1,63 @@
+/**
+ * Shapes returned by the campus leaderboard report endpoint.
+ */
+
+export type CampusChallengeFilter = 'all' | 'public' | 'campus'
+
+export interface CampusParticipation {
+ challengeEndDate: string | null
+ challengeId: string
+ challengeName: string | null
+ challengeStatus: string | null
+ challengeTrack: string | null
+ challengeType: string | null
+ isCampusChallenge: boolean
+ isPublicChallenge: boolean
+ passedReview: boolean
+ placement: number | null
+ registered: boolean
+ registeredAt: string | null
+ score: number | null
+ submitted: boolean
+ submittedDate: string | null
+ won: boolean
+}
+
+export interface CampusLeaderboardMember {
+ challenges: CampusParticipation[]
+ firstName: string | null
+ handle: string | null
+ hasActivity: boolean
+ lastName: string | null
+ memberSince: string | null
+ passingSubmissions: number
+ photoURL: string | null
+ rank: number
+ rating: number | null
+ ratingColor: string | null
+ registrations: number
+ signupDate: string | null
+ submissions: number
+ userId: string
+ wins: number
+}
+
+export interface CampusLeaderboardSummary {
+ membersRegistered: number
+ membersSubmitted: number
+ totalMembers: number
+}
+
+export interface CampusLeaderboardGroup {
+ id: string
+ name: string
+ oldId: string | null
+ privateGroup: boolean
+}
+
+export interface CampusLeaderboard {
+ challengeFilter: CampusChallengeFilter
+ group: CampusLeaderboardGroup
+ members: CampusLeaderboardMember[]
+ summary: CampusLeaderboardSummary
+}
diff --git a/src/apps/campus/src/lib/models/index.ts b/src/apps/campus/src/lib/models/index.ts
new file mode 100644
index 000000000..d4bcf47dd
--- /dev/null
+++ b/src/apps/campus/src/lib/models/index.ts
@@ -0,0 +1 @@
+export * from './campus-leaderboard.model'
diff --git a/src/apps/campus/src/lib/services/campus-leaderboard.service.ts b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts
new file mode 100644
index 000000000..50e3e59b5
--- /dev/null
+++ b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts
@@ -0,0 +1,32 @@
+/**
+ * Read-only client for the campus leaderboard report.
+ */
+import { EnvironmentConfig } from '~/config'
+import { xhrGetAsync } from '~/libs/core'
+
+import { CampusChallengeFilter, CampusLeaderboard } from '../models'
+
+/**
+ * Builds the campus leaderboard report url for a group and challenge filter.
+ *
+ * @param groupName group name taken from the route.
+ * @param challengeFilter challenge visibility filter.
+ * @returns absolute reports api url.
+ */
+export function campusLeaderboardUrl(
+ groupName: string,
+ challengeFilter: CampusChallengeFilter,
+): string {
+ const params: URLSearchParams = new URLSearchParams({ challengeFilter, groupName })
+ return `${EnvironmentConfig.REPORTS_API}/topcoder/leaderboard/campus?${params.toString()}`
+}
+
+/**
+ * Fetches the campus leaderboard for a group.
+ *
+ * @param url campus leaderboard report url.
+ * @returns leaderboard payload.
+ */
+export async function fetchCampusLeaderboard(url: string): Promise {
+ return xhrGetAsync(url)
+}
diff --git a/src/apps/campus/src/lib/services/index.ts b/src/apps/campus/src/lib/services/index.ts
new file mode 100644
index 000000000..cb7a5a248
--- /dev/null
+++ b/src/apps/campus/src/lib/services/index.ts
@@ -0,0 +1 @@
+export * from './campus-leaderboard.service'
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
new file mode 100644
index 000000000..3451b2d1a
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
@@ -0,0 +1,172 @@
+@import '@libs/ui/styles/includes';
+
+.header {
+ margin-top: $sp-8;
+ margin-bottom: $sp-6;
+
+ h1 {
+ margin-bottom: $sp-2;
+ }
+}
+
+.subtitle {
+ color: $black-60;
+}
+
+.stats {
+ display: grid;
+ gap: $sp-4;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ margin-bottom: $sp-6;
+}
+
+.statCard {
+ align-items: center;
+ background: $tc-white;
+ border: 1px solid $black-10;
+ border-radius: 8px;
+ display: flex;
+ gap: $sp-4;
+ padding: $sp-4;
+}
+
+.statIcon {
+ align-items: center;
+ border-radius: 50%;
+ display: flex;
+ flex: 0 0 auto;
+ height: 48px;
+ justify-content: center;
+ width: 48px;
+
+ svg {
+ height: 24px;
+ width: 24px;
+ }
+}
+
+.statIconMembers {
+ background: #e6f7f0;
+ color: #0ab88a;
+}
+
+.statIconRegistered {
+ background: #e9f2fe;
+ color: #2a8ded;
+}
+
+.statIconSubmitted {
+ background: #f0eafc;
+ color: #7b61ff;
+}
+
+.statLabel {
+ color: $black-80;
+ margin-bottom: $sp-1;
+}
+
+.statValue {
+ @include font-barlow-condensed;
+
+ font-size: 28px;
+ font-weight: 500;
+}
+
+.toolbar {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ gap: $sp-4;
+ margin-bottom: $sp-4;
+}
+
+.filter {
+ max-width: 320px;
+ min-width: 220px;
+ width: 100%;
+}
+
+.lbTable {
+ tbody td {
+ vertical-align: middle;
+ }
+}
+
+.rulesLink {
+ align-items: center;
+ background: none;
+ border: none;
+ color: $turq-160;
+ cursor: pointer;
+ display: flex;
+ gap: $sp-2;
+ padding: 0;
+
+ svg {
+ height: 20px;
+ width: 20px;
+ }
+}
+
+.rank {
+ align-items: center;
+ border-radius: 50%;
+ display: inline-flex;
+ font-weight: 700;
+ height: 28px;
+ justify-content: center;
+ width: 28px;
+}
+
+.gold {
+ background: #f5c344;
+ color: $tc-white;
+}
+
+.silver {
+ background: $black-20;
+ color: $black-100;
+}
+
+.bronze {
+ background: #d9a48f;
+ color: $tc-white;
+}
+
+.handleCell {
+ align-items: center;
+ display: flex;
+ gap: $sp-3;
+}
+
+.avatar {
+ flex: 0 0 auto;
+ height: 40px;
+ width: 40px;
+}
+
+.handle {
+ font-weight: 500;
+}
+
+.wins {
+ color: #0ab88a;
+ font-weight: 500;
+}
+
+.chevron {
+ color: $black-60;
+ height: 20px;
+ width: 20px;
+}
+
+.clickableRow {
+ cursor: pointer;
+}
+
+.empty,
+.error {
+ color: $black-60;
+ padding: $sp-6 0;
+ text-align: center;
+}
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
new file mode 100644
index 000000000..0335d3135
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
@@ -0,0 +1,222 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind,
+ react/no-unused-prop-types, react/no-array-index-key, unicorn/no-null */
+import '@testing-library/jest-dom'
+import type { ChangeEvent, PropsWithChildren, ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+
+import { CampusLeaderboard, CampusLeaderboardMember, CampusParticipation } from '../../lib/models'
+
+import { CampusLeaderboardPage } from './CampusLeaderboardPage'
+
+interface StubColumn {
+ columnId?: string
+ label?: string
+ propertyName?: string
+ renderer?: (data: T) => ReactNode
+}
+
+interface StubTableProps {
+ columns: ReadonlyArray>
+ data: ReadonlyArray
+ moreToLoad?: boolean
+ onLoadMoreClick?: () => void
+ onRowClick?: (data: T) => void
+}
+
+interface StubSelectProps {
+ onChange: (event: ChangeEvent) => void
+ options: ReadonlyArray<{ label?: ReactNode, value: string }>
+ value?: string
+}
+
+jest.mock('~/config', () => ({
+ AppSubdomain: { campus: 'campus' },
+ EnvironmentConfig: { REPORTS_API: 'https://api.example.com/v6/reports', SUBDOMAIN: 'campus' },
+}), { virtual: true })
+
+jest.mock('~/libs/shared', () => ({
+ ProfilePicture: (): JSX.Element => ,
+ textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(),
+}), { virtual: true })
+
+jest.mock('~/libs/ui', () => {
+ const Icon = (): JSX.Element =>
+
+ return {
+ BaseModal: (props: PropsWithChildren<{ open?: boolean, title?: ReactNode }>): JSX.Element => (
+ props.open ? (
+
+
{props.title}
+ {props.children}
+
+ ) : <>>
+ ),
+ ContentLayout: (props: PropsWithChildren<{}>): JSX.Element => {props.children}
,
+ IconOutline: new Proxy({}, { get: () => Icon }),
+ InputSelect: (props: StubSelectProps): JSX.Element => (
+
+ ),
+ LoadingSpinner: (props: { hide?: boolean }): JSX.Element => (
+ props.hide ? <>> : Loading
+ ),
+ PageTitle: (): JSX.Element => <>>,
+ Table: (props: StubTableProps): JSX.Element => (
+
+
+ {props.data.map((row, rowIndex) => (
+ props.onRowClick?.(row)}>
+ {props.columns.map(column => (
+ |
+ {column.renderer
+ ? column.renderer(row)
+ : String((row as Record)[column.propertyName ?? ''])}
+ |
+ ))}
+
+ ))}
+
+
+ ),
+ }
+}, { virtual: true })
+
+const mockUseCampusLeaderboard = jest.fn()
+
+jest.mock('../../lib/hooks', () => ({
+ useCampusLeaderboard: (...args: unknown[]) => mockUseCampusLeaderboard(...args),
+}))
+
+const participation = (overrides: Partial = {}): CampusParticipation => ({
+ challengeEndDate: '2026-02-01T00:00:00.000Z',
+ challengeId: 'c1',
+ challengeName: 'Campus Sprint',
+ challengeStatus: 'COMPLETED',
+ challengeTrack: 'Development',
+ challengeType: 'Challenge',
+ isCampusChallenge: true,
+ isPublicChallenge: false,
+ passedReview: true,
+ placement: 1,
+ registered: true,
+ registeredAt: '2026-01-05T00:00:00.000Z',
+ score: 95,
+ submitted: true,
+ submittedDate: '2026-01-20T00:00:00.000Z',
+ won: true,
+ ...overrides,
+})
+
+const member = (overrides: Partial = {}): CampusLeaderboardMember => ({
+ challenges: [participation()],
+ firstName: 'Ada',
+ handle: 'testaws1',
+ hasActivity: true,
+ lastName: 'Lovelace',
+ memberSince: '2025-01-01T00:00:00.000Z',
+ passingSubmissions: 1,
+ photoURL: null,
+ rank: 1,
+ rating: 1500,
+ ratingColor: '#3f3',
+ registrations: 1,
+ signupDate: '2026-01-01T00:00:00.000Z',
+ submissions: 1,
+ userId: '1',
+ wins: 1,
+ ...overrides,
+})
+
+const leaderboard = (): CampusLeaderboard => ({
+ challengeFilter: 'all',
+ group: { id: 'group-1', name: 'MECW', oldId: null, privateGroup: false },
+ members: [
+ member(),
+ member({
+ challenges: [],
+ handle: 'quiet_member',
+ hasActivity: false,
+ passingSubmissions: 0,
+ rank: 2,
+ registrations: 0,
+ submissions: 0,
+ userId: '2',
+ wins: 0,
+ }),
+ ],
+ summary: { membersRegistered: 842, membersSubmitted: 623, totalMembers: 1248 },
+})
+
+function renderPage(): void {
+ render(
+
+
+ } path='/:groupName' />
+
+ ,
+ )
+}
+
+describe('CampusLeaderboardPage', () => {
+ beforeEach(() => {
+ mockUseCampusLeaderboard.mockReturnValue({ data: leaderboard(), isLoading: false })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('requests the leaderboard for the group in the route', () => {
+ renderPage()
+
+ expect(mockUseCampusLeaderboard)
+ .toHaveBeenCalledWith('mecw', 'all')
+ })
+
+ it('renders the participation summary and every group member', () => {
+ renderPage()
+
+ expect(screen.getByText('1,248'))
+ .toBeInTheDocument()
+ expect(screen.getByText('842'))
+ .toBeInTheDocument()
+ expect(screen.getByText('623'))
+ .toBeInTheDocument()
+ expect(screen.getByText('testaws1'))
+ .toBeInTheDocument()
+ expect(screen.getByText('quiet_member'))
+ .toBeInTheDocument()
+ })
+
+ it('opens the participation history only for members with activity', () => {
+ renderPage()
+
+ fireEvent.click(screen.getByText('quiet_member'))
+ expect(screen.queryByText(/Participation History/)).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('testaws1'))
+ expect(screen.getByText(/testaws1 — Participation History/))
+ .toBeInTheDocument()
+ expect(screen.getByText('Campus Sprint'))
+ .toBeInTheDocument()
+ expect(screen.getByText('Won (place 1)'))
+ .toBeInTheDocument()
+ })
+
+ it('re-requests the leaderboard when the challenge filter changes', () => {
+ renderPage()
+
+ fireEvent.change(screen.getByTestId('challenge-filter'), { target: { value: 'campus' } })
+
+ expect(mockUseCampusLeaderboard)
+ .toHaveBeenLastCalledWith('mecw', 'campus')
+ })
+})
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
new file mode 100644
index 000000000..64ac94a98
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
@@ -0,0 +1,301 @@
+/**
+ * Campus program leaderboard for a single group (`/:groupName`).
+ */
+import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react'
+import { useParams } from 'react-router-dom'
+import classNames from 'classnames'
+
+import {
+ ContentLayout,
+ IconOutline,
+ InputSelect,
+ InputSelectOption,
+ LoadingSpinner,
+ PageTitle,
+ Table,
+ TableColumn,
+} from '~/libs/ui'
+import { ProfilePicture } from '~/libs/shared'
+
+import {
+ CampusChallengeFilter,
+ CampusLeaderboardMember,
+} from '../../lib/models'
+import { CampusLeaderboardResource, useCampusLeaderboard } from '../../lib/hooks'
+
+import { ParticipationHistoryModal } from './ParticipationHistoryModal'
+import { RankingRulesModal } from './RankingRulesModal'
+import styles from './CampusLeaderboardPage.module.scss'
+
+const PAGE_SIZE: number = 50
+
+const CHALLENGE_FILTER_OPTIONS: ReadonlyArray = [
+ { label: 'All Challenges', value: 'all' },
+ { label: 'Public Challenges', value: 'public' },
+ { label: 'Campus Challenges', value: 'campus' },
+]
+
+const RANK_MEDAL_CLASSES: { [rank: number]: string } = {
+ 1: styles.gold,
+ 2: styles.silver,
+ 3: styles.bronze,
+}
+
+/**
+ * Marks rows that open the participation history modal.
+ *
+ * @param member leaderboard row.
+ * @returns row class name, when the row is clickable.
+ */
+function rowClassName(member: CampusLeaderboardMember): string | undefined {
+ return member.hasActivity ? styles.clickableRow : undefined
+}
+
+/**
+ * Renders a rank badge, medal-styled for the top three ranks.
+ *
+ * @param member leaderboard row.
+ * @returns rank cell.
+ */
+function renderRank(member: CampusLeaderboardMember): JSX.Element {
+ return (
+
+ {member.rank}
+
+ )
+}
+
+/**
+ * Renders the member avatar and rating-colored handle.
+ *
+ * @param member leaderboard row.
+ * @returns handle cell.
+ */
+function renderHandle(member: CampusLeaderboardMember): JSX.Element {
+ return (
+
+
+
+ {member.handle ?? member.userId}
+
+
+ )
+}
+
+export const CampusLeaderboardPage: FC = () => {
+ const groupName: string | undefined = useParams<{ groupName: string }>().groupName
+ const [challengeFilter, setChallengeFilter] = useState('all')
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
+ const [selectedMember, setSelectedMember] = useState()
+ const [rulesVisible, setRulesVisible] = useState(false)
+
+ const { data, error, isLoading }: CampusLeaderboardResource
+ = useCampusLeaderboard(groupName, challengeFilter)
+
+ const displayGroupName: string = data?.group.name ?? groupName ?? ''
+
+ const onFilterChange = useCallback((event: ChangeEvent): void => {
+ setChallengeFilter(event.target.value as CampusChallengeFilter)
+ setVisibleCount(PAGE_SIZE)
+ }, [])
+
+ const onRowClick = useCallback((member: CampusLeaderboardMember): void => {
+ if (!member.hasActivity) {
+ return
+ }
+
+ setSelectedMember(member)
+ }, [])
+
+ const columns = useMemo>>(() => [
+ {
+ columnId: 'rank',
+ label: 'Rank',
+ renderer: renderRank,
+ type: 'element',
+ },
+ {
+ columnId: 'handle',
+ label: 'Handle',
+ renderer: renderHandle,
+ type: 'element',
+ },
+ {
+ columnId: 'registrations',
+ label: 'Number of Registrations',
+ propertyName: 'registrations',
+ tooltip: 'Challenges the member registered for.',
+ type: 'number',
+ },
+ {
+ columnId: 'submissions',
+ label: 'Number of Submissions',
+ propertyName: 'submissions',
+ tooltip: 'Challenges the member submitted to. At most one submission is counted per challenge.',
+ type: 'number',
+ },
+ {
+ columnId: 'passingSubmissions',
+ label: 'Number of Passing Submissions',
+ propertyName: 'passingSubmissions',
+ tooltip: 'Challenges where a submission passed review. '
+ + 'At most one passing submission is counted per challenge.',
+ type: 'number',
+ },
+ {
+ columnId: 'wins',
+ label: 'Number of Wins',
+ renderer: (member: CampusLeaderboardMember) => (
+ {member.wins}
+ ),
+ type: 'numberElement',
+ },
+ {
+ columnId: 'open',
+ label: '',
+ renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? (
+
+ ) : ),
+ type: 'element',
+ },
+ ], [])
+
+ const members: ReadonlyArray = data?.members ?? []
+ const visibleMembers = useMemo(
+ () => members.slice(0, visibleCount),
+ [members, visibleCount],
+ )
+
+ const onLoadMoreClick = useCallback((): void => {
+ setVisibleCount(count => count + PAGE_SIZE)
+ }, [])
+
+ return (
+
+ Campus Program Leaderboard
+
+
+
Campus Program Leaderboard
+
+ {`Track participation and performance of members in the ${displayGroupName} `}
+ group across challenges.
+
+
+
+
+
+ {!!error && (
+
+ {error.response?.status === 403
+ ? 'You do not have access to this leaderboard.'
+ : `The leaderboard for "${displayGroupName}" could not be loaded.`}
+
+ )}
+
+ {!!data && (
+ <>
+
+
+
+
+
+
+
Total Members in Group
+
+ {data.summary.totalMembers.toLocaleString()}
+
+
+
+
+
+
+
+
+
+ Members
+ {' Registered to Any Challenge'}
+
+
+ {data.summary.membersRegistered.toLocaleString()}
+
+
+
+
+
+
+
+
+
+ Members
+ {' Submitted to Any Challenge'}
+
+
+ {data.summary.membersSubmitted.toLocaleString()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!members.length && (
+
+ {`No members were found in the ${displayGroupName} group.`}
+
+ )}
+ >
+ )}
+
+
+
+
+
+ )
+}
+
+export default CampusLeaderboardPage
diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss
new file mode 100644
index 000000000..d21d0279d
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss
@@ -0,0 +1,23 @@
+@import '@libs/ui/styles/includes';
+
+.summary {
+ color: $black-80;
+ display: flex;
+ flex-wrap: wrap;
+ gap: $sp-5;
+ margin-bottom: $sp-4;
+}
+
+.challengeCell {
+ display: flex;
+ flex-direction: column;
+}
+
+.challengeName {
+ font-weight: 500;
+}
+
+.challengeMeta {
+ color: $black-60;
+ font-size: 12px;
+}
diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx
new file mode 100644
index 000000000..f372c13f2
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx
@@ -0,0 +1,124 @@
+/**
+ * Participation history for one leaderboard member.
+ */
+import { FC, useMemo } from 'react'
+
+import { BaseModal, Table, TableColumn } from '~/libs/ui'
+import { textFormatDateLocaleShortString } from '~/libs/shared'
+
+import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models'
+
+import styles from './ParticipationHistoryModal.module.scss'
+
+interface ParticipationHistoryModalProps {
+ member?: CampusLeaderboardMember
+ onClose: () => void
+}
+
+/**
+ * Formats an api date as a short local date.
+ *
+ * @param value iso date string.
+ * @returns formatted date or an em dash.
+ */
+function formatDate(value: string | null): string {
+ return (value ? textFormatDateLocaleShortString(new Date(value)) : undefined) ?? '—'
+}
+
+/**
+ * Describes the outcome of a member's participation in a challenge.
+ *
+ * @param entry participation entry.
+ * @returns human readable result.
+ */
+function formatResult(entry: CampusParticipation): string {
+ if (entry.won) {
+ return entry.placement ? `Won (place ${entry.placement})` : 'Won'
+ }
+
+ if (entry.passedReview) {
+ return 'Passed review'
+ }
+
+ if (entry.submitted) {
+ return 'Did not pass review'
+ }
+
+ return 'No submission'
+}
+
+export const ParticipationHistoryModal: FC = props => {
+ const member: CampusLeaderboardMember | undefined = props.member
+
+ const columns = useMemo>>(() => [
+ {
+ columnId: 'challenge',
+ label: 'Challenge',
+ renderer: (entry: CampusParticipation) => (
+
+ {entry.challengeName ?? entry.challengeId}
+
+ {[entry.challengeTrack, entry.challengeType].filter(Boolean)
+ .join(' • ')}
+
+
+ ),
+ type: 'element',
+ },
+ {
+ columnId: 'registeredAt',
+ label: 'Registered',
+ renderer: (entry: CampusParticipation) => {formatDate(entry.registeredAt)},
+ type: 'element',
+ },
+ {
+ columnId: 'submittedDate',
+ label: 'Submitted',
+ renderer: (entry: CampusParticipation) => {formatDate(entry.submittedDate)},
+ type: 'element',
+ },
+ {
+ columnId: 'result',
+ label: 'Result',
+ renderer: (entry: CampusParticipation) => {formatResult(entry)},
+ type: 'element',
+ },
+ ], [])
+
+ if (!member) {
+ return <>>
+ }
+
+ return (
+
+
+
+ {`${member.registrations} registrations`}
+
+
+ {`${member.submissions} submissions`}
+
+
+ {`${member.passingSubmissions} passing`}
+
+
+ {`${member.wins} wins`}
+
+
+
+
+
+ )
+}
+
+export default ParticipationHistoryModal
diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss
new file mode 100644
index 000000000..3163ddb50
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss
@@ -0,0 +1,14 @@
+@import '@libs/ui/styles/includes';
+
+.rules {
+ list-style: decimal outside;
+ margin: $sp-3 0 $sp-4 $sp-5;
+
+ li {
+ margin-bottom: $sp-1;
+ }
+}
+
+.note {
+ color: $black-60;
+}
diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx
new file mode 100644
index 000000000..d7bebd976
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx
@@ -0,0 +1,43 @@
+/**
+ * Explains the leaderboard ranking criteria.
+ */
+import { FC } from 'react'
+
+import { BaseModal } from '~/libs/ui'
+
+import styles from './RankingRulesModal.module.scss'
+
+interface RankingRulesModalProps {
+ onClose: () => void
+ open: boolean
+}
+
+export const RankingRulesModal: FC = props => {
+ if (!props.open) {
+ return <>>
+ }
+
+ return (
+
+ Members are ranked by the following criteria, in order:
+
+ - Number of wins, highest first
+ - Number of passing submissions, highest first
+ - Number of registrations, highest first
+ - Signup time, earliest first
+
+
+ At most one submission and one passing submission are counted per member per
+ challenge. Every member of the group is listed, including members with no
+ challenge activity.
+
+
+ )
+}
+
+export default RankingRulesModal
diff --git a/src/apps/campus/src/pages/leaderboard/index.ts b/src/apps/campus/src/pages/leaderboard/index.ts
new file mode 100644
index 000000000..e6be4c579
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/index.ts
@@ -0,0 +1,3 @@
+export { default as CampusLeaderboardPage } from './CampusLeaderboardPage'
+export { default as ParticipationHistoryModal } from './ParticipationHistoryModal'
+export { default as RankingRulesModal } from './RankingRulesModal'
diff --git a/tsconfig.paths.json b/tsconfig.paths.json
index 54e6a260e..809bc6b7c 100644
--- a/tsconfig.paths.json
+++ b/tsconfig.paths.json
@@ -27,6 +27,9 @@
"@wallet/*": [
"./src/apps/wallet/src/*"
],
+ "@campus/*": [
+ "./src/apps/campus/src/*"
+ ],
"@walletAdmin/*": [
"./src/apps/wallet-admin/src/*"
],
From 115599f162e47bddd41373607ff925b2c5245804 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 18 Aug 2026 14:35:50 +0300
Subject: [PATCH 3/7] PM-5370 - campus data loading
---
.../CampusLeaderboardPage.module.scss | 4 ++
.../leaderboard/CampusLeaderboardPage.tsx | 56 +++++++++++--------
2 files changed, 38 insertions(+), 22 deletions(-)
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
index 3451b2d1a..947620fba 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
@@ -86,6 +86,10 @@
width: 100%;
}
+.tableWrapper {
+ position: relative;
+}
+
.lbTable {
tbody td {
vertical-align: middle;
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
index 64ac94a98..f00d69160 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
@@ -2,7 +2,7 @@
* Campus program leaderboard for a single group (`/:groupName`).
*/
import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react'
-import { useParams } from 'react-router-dom'
+import { useParams, useSearchParams } from 'react-router-dom'
import classNames from 'classnames'
import {
@@ -94,7 +94,12 @@ function renderHandle(member: CampusLeaderboardMember): JSX.Element {
export const CampusLeaderboardPage: FC = () => {
const groupName: string | undefined = useParams<{ groupName: string }>().groupName
- const [challengeFilter, setChallengeFilter] = useState('all')
+ const [searchParams, setSearchParams] = useSearchParams()
+ const searchChallengeFilter: string | null = searchParams.get('type')
+ const challengeFilter: CampusChallengeFilter = (
+ searchChallengeFilter === 'public' || searchChallengeFilter === 'campus'
+ ) ? searchChallengeFilter : 'all'
+
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const [selectedMember, setSelectedMember] = useState()
const [rulesVisible, setRulesVisible] = useState(false)
@@ -105,9 +110,15 @@ export const CampusLeaderboardPage: FC = () => {
const displayGroupName: string = data?.group.name ?? groupName ?? ''
const onFilterChange = useCallback((event: ChangeEvent): void => {
- setChallengeFilter(event.target.value as CampusChallengeFilter)
+ const value = event.target.value as CampusChallengeFilter
+
+ setSearchParams({
+ ...Object.fromEntries(searchParams.entries()),
+ type: value,
+ }, { replace: true })
+
setVisibleCount(PAGE_SIZE)
- }, [])
+ }, [searchParams, setSearchParams])
const onRowClick = useCallback((member: CampusLeaderboardMember): void => {
if (!member.hasActivity) {
@@ -192,8 +203,6 @@ export const CampusLeaderboardPage: FC = () => {
-
-
{!!error && (
{error.response?.status === 403
@@ -202,7 +211,7 @@ export const CampusLeaderboardPage: FC = () => {
)}
- {!!data && (
+ {(!!data || isLoading) && (
<>
@@ -212,7 +221,7 @@ export const CampusLeaderboardPage: FC = () => {
Total Members in Group
- {data.summary.totalMembers.toLocaleString()}
+ {data?.summary.totalMembers.toLocaleString() ?? '-'}
@@ -226,7 +235,7 @@ export const CampusLeaderboardPage: FC = () => {
{' Registered to Any Challenge'}
- {data.summary.membersRegistered.toLocaleString()}
+ {data?.summary.membersRegistered.toLocaleString() ?? '-'}
@@ -240,7 +249,7 @@ export const CampusLeaderboardPage: FC = () => {
{' Submitted to Any Challenge'}
- {data.summary.membersSubmitted.toLocaleString()}
+ {data?.summary.membersSubmitted.toLocaleString() ?? '-'}
@@ -265,19 +274,22 @@ export const CampusLeaderboardPage: FC = () => {
-
+
- {!members.length && (
+ {!isLoading && !members.length && (
{`No members were found in the ${displayGroupName} group.`}
From 824dd183999aac97344848bc0b5f2683bb639725 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 18 Aug 2026 14:57:00 +0300
Subject: [PATCH 4/7] PM-5370 - clear out old code
---
src/apps/campus/src/campus.routes.spec.tsx | 56 ++++++++++++++++++++++
src/apps/campus/src/campus.routes.tsx | 9 +---
src/apps/campus/src/home.tsx | 10 ----
3 files changed, 58 insertions(+), 17 deletions(-)
create mode 100644 src/apps/campus/src/campus.routes.spec.tsx
delete mode 100644 src/apps/campus/src/home.tsx
diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx
new file mode 100644
index 000000000..c5db10f66
--- /dev/null
+++ b/src/apps/campus/src/campus.routes.spec.tsx
@@ -0,0 +1,56 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+
+import { campusRoutes, rootRoute } from './campus.routes'
+
+jest.mock('~/config', () => ({
+ EnvironmentConfig: {
+ SUBDOMAIN: 'campus',
+ },
+}), {
+ virtual: true,
+})
+
+jest.mock('~/config/constants', () => ({
+ AppSubdomain: {
+ campus: 'campus',
+ },
+ ToolTitle: {
+ campus: 'Campus',
+ },
+}), {
+ virtual: true,
+})
+
+jest.mock('~/libs/core', () => ({
+ lazyLoad: () => (): undefined => undefined,
+}), {
+ virtual: true,
+})
+
+const LocationViewer = (): JSX.Element => {
+ const location = useLocation()
+
+ return {location.pathname}
+}
+
+describe('campus routes', () => {
+ it('redirects the campus root to /mecw when groupName is missing', async () => {
+ const campusAppRoute = campusRoutes[0]
+ const campusChildRoutes = campusAppRoute.children || []
+ const fallbackRoute = campusChildRoutes.find(route => route.route === '')
+
+ render(
+
+
+ } path={`${rootRoute}/mecw`} />
+
+
+ ,
+ )
+
+ expect((await screen.findByTestId('location-pathname')).textContent)
+ .toBe('/mecw')
+ })
+})
diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx
index 61efd6028..3bfb76248 100644
--- a/src/apps/campus/src/campus.routes.tsx
+++ b/src/apps/campus/src/campus.routes.tsx
@@ -1,11 +1,8 @@
import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
+import { Navigate } from 'react-router-dom'
import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config'
const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp'))
-const CampusHomePage: LazyLoadedComponent = lazyLoad(
- () => import('./home'),
- 'CampusHomePage',
-)
const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad(
() => import('./pages/leaderboard'),
'CampusLeaderboardPage',
@@ -22,9 +19,7 @@ export const campusRoutes: ReadonlyArray = [
authRequired: true,
children: [
{
- children: [],
- element: ,
- id: 'Campus Home',
+ element: ,
route: '',
},
{
diff --git a/src/apps/campus/src/home.tsx b/src/apps/campus/src/home.tsx
deleted file mode 100644
index 93568a303..000000000
--- a/src/apps/campus/src/home.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { FC } from 'react'
-
-const CampusHomePage: FC = () => (
-
-
Campus
-
Welcome to the Campus application.
-
-)
-
-export default CampusHomePage
From e2f296d1739a250a30e370bb6c7f3cf47432743b Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 18 Aug 2026 15:19:14 +0300
Subject: [PATCH 5/7] PM-5370 - clickable row
---
src/apps/campus/src/campus.routes.spec.tsx | 2 +-
.../CampusLeaderboardPage.module.scss | 15 +++++++++++----
.../CampusLeaderboardPage.spec.tsx | 11 +++++++----
.../leaderboard/CampusLeaderboardPage.tsx | 19 ++++++++++---------
4 files changed, 29 insertions(+), 18 deletions(-)
diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx
index c5db10f66..b42d5d4f3 100644
--- a/src/apps/campus/src/campus.routes.spec.tsx
+++ b/src/apps/campus/src/campus.routes.spec.tsx
@@ -42,7 +42,7 @@ describe('campus routes', () => {
const fallbackRoute = campusChildRoutes.find(route => route.route === '')
render(
-
+
} path={`${rootRoute}/mecw`} />
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
index 947620fba..3505c8520 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
@@ -158,16 +158,23 @@
font-weight: 500;
}
+.chevronButton {
+ align-items: center;
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ justify-content: center;
+ padding: 0;
+}
+
.chevron {
color: $black-60;
height: 20px;
width: 20px;
}
-.clickableRow {
- cursor: pointer;
-}
-
.empty,
.error {
color: $black-60;
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
index 0335d3135..a520a79c9 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
@@ -196,13 +196,16 @@ describe('CampusLeaderboardPage', () => {
.toBeInTheDocument()
})
- it('opens the participation history only for members with activity', () => {
+ it('opens the participation history only when the chevron is clicked for active members', () => {
renderPage()
- fireEvent.click(screen.getByText('quiet_member'))
- expect(screen.queryByText(/Participation History/)).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', {
+ name: /View participation history for quiet_member/i,
+ })).not.toBeInTheDocument()
- fireEvent.click(screen.getByText('testaws1'))
+ fireEvent.click(screen.getByRole('button', {
+ name: /View participation history for testaws1/i,
+ }))
expect(screen.getByText(/testaws1 — Participation History/))
.toBeInTheDocument()
expect(screen.getByText('Campus Sprint'))
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
index f00d69160..ef71516f8 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
@@ -47,10 +47,6 @@ const RANK_MEDAL_CLASSES: { [rank: number]: string } = {
* @param member leaderboard row.
* @returns row class name, when the row is clickable.
*/
-function rowClassName(member: CampusLeaderboardMember): string | undefined {
- return member.hasActivity ? styles.clickableRow : undefined
-}
-
/**
* Renders a rank badge, medal-styled for the top three ranks.
*
@@ -120,7 +116,7 @@ export const CampusLeaderboardPage: FC = () => {
setVisibleCount(PAGE_SIZE)
}, [searchParams, setSearchParams])
- const onRowClick = useCallback((member: CampusLeaderboardMember): void => {
+ const openParticipationHistory = useCallback((member: CampusLeaderboardMember): void => {
if (!member.hasActivity) {
return
}
@@ -175,11 +171,18 @@ export const CampusLeaderboardPage: FC = () => {
columnId: 'open',
label: '',
renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? (
-
+
) : ),
type: 'element',
},
- ], [])
+ ], [openParticipationHistory])
const members: ReadonlyArray = data?.members ?? []
const visibleMembers = useMemo(
@@ -283,9 +286,7 @@ export const CampusLeaderboardPage: FC = () => {
disableSorting
moreToLoad={visibleCount < members.length}
onLoadMoreClick={onLoadMoreClick}
- onRowClick={onRowClick}
removeDefaultSort
- rowClassName={rowClassName}
/>
From 6340c2c2749acb687aec66ed669fa8f285fce53c Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 18 Aug 2026 15:34:17 +0300
Subject: [PATCH 6/7] lint
---
src/apps/campus/src/campus.routes.tsx | 3 ++-
.../campus/src/pages/leaderboard/CampusLeaderboardPage.tsx | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx
index 3bfb76248..6426a9b61 100644
--- a/src/apps/campus/src/campus.routes.tsx
+++ b/src/apps/campus/src/campus.routes.tsx
@@ -1,5 +1,6 @@
-import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
import { Navigate } from 'react-router-dom'
+
+import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config'
const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp'))
diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
index ef71516f8..8e9023c41 100644
--- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
@@ -173,7 +173,7 @@ export const CampusLeaderboardPage: FC = () => {
renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? (