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:

+
    +
  1. Number of wins, highest first
  2. +
  3. Number of passing submissions, highest first
  4. +
  5. Number of registrations, highest first
  6. +
  7. Signup time, earliest first
  8. +
+

+ 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/*" ],