diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts
new file mode 100644
index 000000000..3c2923024
--- /dev/null
+++ b/src/apps/campus/index.ts
@@ -0,0 +1 @@
+export { campusRoutes } 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.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx
new file mode 100644
index 000000000..b42d5d4f3
--- /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
new file mode 100644
index 000000000..6426a9b61
--- /dev/null
+++ b/src/apps/campus/src/campus.routes.tsx
@@ -0,0 +1,40 @@
+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'))
+const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad(
+ () => import('./pages/leaderboard'),
+ 'CampusLeaderboardPage',
+)
+
+export const rootRoute: string = (
+ EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}`
+)
+
+export const toolTitle: string = ToolTitle.campus
+
+export const campusRoutes: ReadonlyArray = [
+ {
+ authRequired: true,
+ children: [
+ {
+ element: ,
+ route: '',
+ },
+ {
+ // Campus program leaderboard, eg. https://campus.topcoder-dev.com/mecw
+ children: [],
+ element: ,
+ id: 'Campus Leaderboard',
+ route: ':groupName',
+ },
+ ],
+ domain: AppSubdomain.campus,
+ element: ,
+ id: toolTitle,
+ route: rootRoute,
+ title: toolTitle,
+ },
+]
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/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..3505c8520
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss
@@ -0,0 +1,183 @@
+@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%;
+}
+
+.tableWrapper {
+ position: relative;
+}
+
+.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;
+}
+
+.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;
+}
+
+.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..a520a79c9
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx
@@ -0,0 +1,225 @@
+/* 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 when the chevron is clicked for active members', () => {
+ renderPage()
+
+ expect(screen.queryByRole('button', {
+ name: /View participation history for quiet_member/i,
+ })).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: /View participation history for testaws1/i,
+ }))
+ 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..f7e1ab606
--- /dev/null
+++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx
@@ -0,0 +1,314 @@
+/**
+ * Campus program leaderboard for a single group (`/:groupName`).
+ */
+import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react'
+import { useParams, useSearchParams } 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.
+ */
+/**
+ * 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 [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)
+
+ const { data, error, isLoading }: CampusLeaderboardResource
+ = useCampusLeaderboard(groupName, challengeFilter)
+
+ const displayGroupName: string = data?.group.name ?? groupName ?? ''
+
+ const onFilterChange = useCallback((event: ChangeEvent): void => {
+ const value = event.target.value as CampusChallengeFilter
+
+ setSearchParams({
+ ...Object.fromEntries(searchParams.entries()),
+ type: value,
+ }, { replace: true })
+
+ setVisibleCount(PAGE_SIZE)
+ }, [searchParams, setSearchParams])
+
+ const openParticipationHistory = 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',
+ },
+ ], [openParticipationHistory])
+
+ 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 || isLoading) && (
+ <>
+
+
+
+
+
+
+
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() ?? '-'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isLoading && !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/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',
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/*"
],