Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/front/src/components/GameContext/useGameSetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,9 @@ vi.mock('../../utils/api', () => ({
}
},
createWebSocketTicket: vi.fn(),
deleteDisplayName: vi.fn(),
initGame: vi.fn(),
play: vi.fn(),
reportClientProtocolDiagnostic: vi.fn(),
updateDisplayName: vi.fn(),
voteRematch: vi.fn()
}))
vi.mock('../../utils/playerIdentity', async (importOriginal) => ({
Expand Down
23 changes: 1 addition & 22 deletions apps/front/src/components/GameContext/useGameSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
GameState,
getGameStateMessagePayload,
type IGameState,
isEmptyOrBlank,
PROTOCOL_VERSION,
type GameSettings
} from '@knucklebones/common'
Expand All @@ -18,8 +17,6 @@ import { useRoomKey } from '../../hooks/useRoomKey'
import {
ApiRequestError,
createWebSocketTicket,
deleteDisplayName,
updateDisplayName,
initGame,
play,
reportClientProtocolDiagnostic,
Expand Down Expand Up @@ -354,23 +351,6 @@ export function useGameSetup() {
})
}

async function _updateDisplayName(newDisplayName: string) {
if (isEmptyOrBlank(newDisplayName)) {
await deleteDisplayName({ roomKey, playerId: playerId! }).catch(
(error) => {
setErrorMessage(error.message)
}
)
} else {
await updateDisplayName(
{ roomKey, playerId: playerId! },
{ displayName: newDisplayName }
).catch((error) => {
setErrorMessage(error.message)
})
}
}

// Easy way to do a type guard
if (identityError !== null) {
return {
Expand Down Expand Up @@ -401,7 +381,6 @@ export function useGameSetup() {
voteContinueBo,
voteContinueIndefinitely,
voteRematch: _voteRematch,
resign,
updateDisplayName: _updateDisplayName
resign
}
}
1 change: 1 addition & 0 deletions apps/front/src/components/GameOutcome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('GameOutcome ranked rating', () => {
vi.mocked(getRankedProfile).mockReset()
vi.mocked(getRankedProfile).mockResolvedValue({
playerId: '11111111-1111-4111-8111-111111111111',
displayName: 'Player One',
ratingPool: 'classic',
rating: 1184,
gamesPlayed: 1,
Expand Down
11 changes: 10 additions & 1 deletion apps/front/src/components/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { UserCircleIcon } from '@heroicons/react/24/outline'
import { TrophyIcon, UserCircleIcon } from '@heroicons/react/24/outline'
import { type PlayerType } from '@knucklebones/common'
import { useLocalizedPath } from '../hooks/useLocalizedPath'
import KnucklebonesLogo from '../svgs/logo.svg'
Expand Down Expand Up @@ -70,6 +70,15 @@ export function HomePage() {
>
<UserCircleIcon className='h-8 w-8 md:h-10 md:w-10' />
</Button>
<Button
as={Link}
size='large'
to={localizedPath('/leaderboard')}
aria-label={t('leaderboard.label')}
title={t('leaderboard.label')}
>
<TrophyIcon className='h-8 w-8 md:h-10 md:w-10' />
</Button>
</div>
<div className='absolute bottom-0 flex flex-col gap-2 p-2'>
<Footer />
Expand Down
70 changes: 70 additions & 0 deletions apps/front/src/components/Leaderboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getRankedLeaderboard } from '../utils/api'
import { LeaderboardPage } from './Leaderboard'

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' }
})
}))
vi.mock('../utils/api', () => ({ getRankedLeaderboard: vi.fn() }))

const topPlayers = Array.from({ length: 10 }, (_, index) => ({
rank: index + 1,
playerId: `${String(index + 1).padStart(8, '0')}-1111-4111-8111-111111111111`,
displayName: index === 0 ? 'Champion 🧙' : `Player ${index + 1}`,
rating: 1500 - index * 20
}))

describe('LeaderboardPage', () => {
beforeEach(() => {
vi.mocked(getRankedLeaderboard)
.mockReset()
.mockResolvedValue({
topPlayers,
currentPlayer: {
rank: 24,
playerId: '99999999-1111-4111-8111-111111111111',
displayName: 'Current Player',
rating: 1160
}
})
})

it('shows the podium, top ten, and current player position', async () => {
render(<LeaderboardPage />)

expect(await screen.findByText('leaderboard.title')).toBeVisible()
expect(screen.getAllByText('Champion 🧙')).toHaveLength(2)
expect(screen.getByText('Player 10')).toBeVisible()
expect(screen.getByText('Current Player')).toBeVisible()
expect(screen.getByText('#24')).toBeVisible()
})

it('offers a retry after a load failure', async () => {
vi.mocked(getRankedLeaderboard)
.mockRejectedValueOnce(new Error('Unavailable'))
.mockResolvedValueOnce({
topPlayers: [],
currentPlayer: {
rank: null,
playerId: '99999999-1111-4111-8111-111111111111',
displayName: 'New Player',
rating: 1200
}
})
const user = userEvent.setup()
render(<LeaderboardPage />)

expect(await screen.findByRole('alert')).toHaveTextContent(
'leaderboard.error'
)
await user.click(screen.getByRole('button', { name: 'leaderboard.retry' }))

expect(await screen.findByText('leaderboard.empty')).toBeVisible()
expect(screen.getByText('leaderboard.unranked')).toBeVisible()
})
})
194 changes: 194 additions & 0 deletions apps/front/src/components/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { type RankedLeaderboardEntry } from '@knucklebones/common'
import { useNoIndex } from '../hooks/useNoIndex'
import { getRankedLeaderboard } from '../utils/api'
import { Button } from './Button'

interface PodiumPlaceProps {
player: RankedLeaderboardEntry
formatNumber(value: number): string
}

function PodiumPlace({ player, formatNumber }: PodiumPlaceProps) {
const height =
player.rank === 1 ? 'h-48 md:h-56' : player.rank === 2 ? 'h-40' : 'h-32'
const color =
player.rank === 1
? 'bg-amber-300 dark:bg-amber-500'
: player.rank === 2
? 'bg-slate-300 dark:bg-slate-500'
: 'bg-orange-300 dark:bg-orange-700'

return (
<article
className={`${height} ${color} flex min-w-0 flex-col items-center justify-start rounded-t-2xl px-2 py-4 text-center text-slate-900 shadow-md`}
>
<span className='text-2xl font-bold'>#{player.rank}</span>
<h2
className='mt-2 w-full truncate font-semibold'
title={player.displayName}
>
{player.displayName}
</h2>
<p className='mt-1 text-sm font-medium'>
{formatNumber(player.rating)} Elo
</p>
</article>
)
}

export function LeaderboardPage() {
const { t, i18n } = useTranslation()
const [leaderboard, setLeaderboard] =
React.useState<Awaited<ReturnType<typeof getRankedLeaderboard>>>()
const [hasError, setHasError] = React.useState(false)
const [loadAttempt, setLoadAttempt] = React.useState(0)
useNoIndex()

React.useEffect(() => {
let disposed = false

async function loadLeaderboard() {
setHasError(false)
try {
const nextLeaderboard = await getRankedLeaderboard()
if (!disposed) {
setLeaderboard(nextLeaderboard)
}
} catch {
if (!disposed) {
setHasError(true)
}
}
}

void loadLeaderboard()
return () => {
disposed = true
}
}, [loadAttempt])

if (leaderboard === undefined) {
return (
<main className='flex min-h-96 items-center justify-center px-4'>
{hasError ? (
<div className='flex flex-col items-center gap-4 text-center'>
<p className='text-xl font-medium' role='alert'>
{t('leaderboard.error')}
</p>
<Button onClick={() => setLoadAttempt((attempt) => attempt + 1)}>
{t('leaderboard.retry')}
</Button>
</div>
) : (
<p className='text-xl font-medium' aria-live='polite'>
{t('leaderboard.loading')}
</p>
)}
</main>
)
}

const numberFormatter = new Intl.NumberFormat(i18n.language)
const formatNumber = (value: number) => numberFormatter.format(value)
const podiumPlayers = [
leaderboard.topPlayers[1],
leaderboard.topPlayers[0],
leaderboard.topPlayers[2]
]

return (
<main className='container mx-auto max-w-4xl px-4 py-8 md:px-6'>
<h1 className='font-mona text-center text-4xl font-bold tracking-tight md:text-6xl'>
{t('leaderboard.title')}
</h1>

{leaderboard.topPlayers.length === 0 ? (
<p className='mt-12 text-center text-xl'>{t('leaderboard.empty')}</p>
) : (
<>
<section
className='mx-auto mt-10 grid max-w-2xl grid-cols-3 items-end gap-2 md:gap-4'
aria-label={t('leaderboard.podium')}
>
{podiumPlayers.map((player, index) =>
player === undefined ? (
<div key={index} />
) : (
<PodiumPlace
key={player.playerId}
player={player}
formatNumber={formatNumber}
/>
)
)}
</section>

<div className='mt-10 overflow-hidden rounded-2xl border border-slate-900/10 bg-white/70 shadow-sm dark:border-slate-50/10 dark:bg-slate-800/70'>
<table className='w-full table-fixed'>
<thead className='bg-slate-200/70 text-left dark:bg-slate-700/70'>
<tr>
<th className='w-20 px-4 py-3'>{t('leaderboard.rank')}</th>
<th className='px-4 py-3'>{t('leaderboard.player')}</th>
<th className='w-28 px-4 py-3 text-right'>
{t('leaderboard.elo')}
</th>
</tr>
</thead>
<tbody>
{leaderboard.topPlayers.map((player) => {
const isCurrentPlayer =
player.playerId === leaderboard.currentPlayer.playerId
return (
<tr
key={player.playerId}
className={
isCurrentPlayer
? 'bg-indigo-100 font-semibold dark:bg-indigo-950'
: 'border-t border-slate-900/10 dark:border-slate-50/10'
}
>
<td className='px-4 py-3'>#{player.rank}</td>
<td
className='truncate px-4 py-3'
title={player.displayName}
>
{player.displayName}
</td>
<td className='px-4 py-3 text-right'>
{formatNumber(player.rating)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}

<section className='mt-8' aria-labelledby='current-player-position'>
<h2 id='current-player-position' className='mb-3 text-xl font-semibold'>
{t('leaderboard.your-position')}
</h2>
<div className='grid grid-cols-[5rem_1fr_7rem] items-center rounded-2xl border-2 border-indigo-400 bg-indigo-100 px-4 py-4 font-semibold dark:border-indigo-500 dark:bg-indigo-950'>
<span>
{leaderboard.currentPlayer.rank === null
? t('leaderboard.unranked')
: `#${leaderboard.currentPlayer.rank}`}
</span>
<span
className='truncate'
title={leaderboard.currentPlayer.displayName}
>
{leaderboard.currentPlayer.displayName}
</span>
<span className='text-right'>
{formatNumber(leaderboard.currentPlayer.rating)}
</span>
</div>
</section>
</main>
)
}
10 changes: 3 additions & 7 deletions apps/front/src/components/PlayerBoard/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ interface BoardProps {
interface PlayerBoardProps extends IPlayer, BoardProps {
isNextPlayer: boolean
outcome: Outcome
isDisplayNameEditable?: boolean
updateDisplayName?(displayName: string): void
isCurrentPlayer?: boolean
}

const MAX_COLUMNS = 3
Expand Down Expand Up @@ -100,8 +99,7 @@ export function PlayerBoard({
canPlay,
isNextPlayer,
onColumnClick,
updateDisplayName,
isDisplayNameEditable = false,
isCurrentPlayer = false,
outcome
}: PlayerBoardProps) {
const { t } = useTranslation()
Expand All @@ -117,9 +115,7 @@ export function PlayerBoard({
id={id}
difficulty={difficulty}
displayName={displayName}
isPlayerOne={isPlayerOne}
updateDisplayName={updateDisplayName}
isEditable={isDisplayNameEditable}
isCurrentPlayer={isCurrentPlayer}
/>
<div
className={clsx('grid-cols-3-central grid gap-4 md:gap-8', {
Expand Down
Loading
Loading