opponents table

This commit is contained in:
2025-04-23 13:43:53 +02:00
parent c3276e7759
commit 19bc1eae8e
3 changed files with 329 additions and 46 deletions

View File

@@ -417,7 +417,12 @@ export default function LogParser() {
))}
</div>
<div>
{moneyReports.map((report, i) => (
{moneyReports.map((report, i) => {
const mostShops =
report.spentPerShop.length > report.spentPerShopOpponent.length
? report.spentPerShop.length
: report.spentPerShopOpponent.length
return (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<div key={i}>
<div className='font-bold text-lg'>Game {i + 1}</div>
@@ -436,14 +441,14 @@ export default function LogParser() {
</TableRow>
</TableHeader>
<TableBody>
{report.spentPerShop.map((spent, j) => (
{Array.from({ length: mostShops }).map((_, j) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TableRow key={j}>
<TableCell className={'text-right font-mono'}>
{j + 1}
</TableCell>
<TableCell className={'text-right font-mono'}>
{spent ?? 'Skipped'}
{report.spentPerShop[j] ?? 'Skipped'}
</TableCell>
<TableCell className={'text-right font-mono'}>
{report.spentPerShopOpponent[j] ?? 'Skipped'}
@@ -462,7 +467,8 @@ export default function LogParser() {
</TableBody>
</Table>
</div>
))}
)
})}
</div>
</div>
</div>

View File

@@ -0,0 +1,266 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
import type { SelectGames } from '@/server/db/types'
import {
type SortingState,
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
ArrowDownCircle,
ArrowUp,
ArrowUpCircle,
MinusCircle,
} from 'lucide-react'
import Link from 'next/link'
import { memo, useMemo, useState } from 'react'
import { groupBy } from 'remeda'
const numberFormatter = new Intl.NumberFormat('en-US', {
signDisplay: 'exceptZero',
})
const columnHelper = createColumnHelper<Stats>()
const useColumns = () => {
return useMemo(
() => [
columnHelper.accessor('opponentName', {
meta: { className: 'pl-4' },
header: 'Opponent',
cell: (info) => (
<Link
href={`/players/${info.row.original.opponentId}`}
className='pl-4 font-medium hover:underline'
>
{info.getValue()}
</Link>
),
}),
columnHelper.accessor('totalGames', {
header: 'Games Played',
meta: { className: 'justify-end' },
cell: (info) => {
const totalGames = info.getValue()
return (
<span className='flex w-full justify-end font-mono'>
{totalGames}
</span>
)
},
}),
columnHelper.accessor('wins', {
header: 'Wins',
meta: { className: 'justify-end' },
cell: (info) => {
const wins = info.getValue()
return (
<span className='flex w-full justify-end font-mono'>{wins}</span>
)
},
}),
columnHelper.accessor('losses', {
header: 'Losses',
meta: { className: 'justify-end' },
cell: (info) => {
const losses = info.getValue()
return (
<span className='flex w-full justify-end font-mono'>{losses}</span>
)
},
}),
columnHelper.accessor('winRate', {
header: 'Win rate',
meta: { className: 'justify-end' },
cell: (info) => {
const winRate = info.getValue()
return (
<span
className={cn(
'flex items-center justify-end font-medium font-mono',
winRate === 50
? 'text-zink-800 dark:text-zink-200'
: winRate > 50
? 'text-emerald-500'
: 'text-rose-500'
)}
>
{Math.round(winRate)}%
</span>
)
},
}),
columnHelper.accessor('totalMMRChange', {
header: 'Total MMR change',
meta: { className: 'justify-end' },
cell: (info) => {
const mmrChange = info.getValue()
return (
<span
className={cn(
'flex items-center justify-end font-medium font-mono',
mmrChange === 0
? 'text-zink-800 dark:text-zink-200'
: mmrChange > 0
? 'text-emerald-500'
: 'text-rose-500'
)}
>
{numberFormatter.format(Math.trunc(mmrChange))}
{mmrChange === 0 ? (
<MinusCircle className='ml-1 h-4 w-4' />
) : mmrChange > 0 ? (
<ArrowUpCircle className='ml-1 h-4 w-4' />
) : (
<ArrowDownCircle className='ml-1 h-4 w-4' />
)}
</span>
)
},
}),
],
[]
)
}
type Stats = {
totalGames: number
wins: number
losses: number
opponentName: string
opponentId: string
totalMMRChange: number
winRate: number
}
function RawOpponentsTable({ games }: { games: SelectGames[] }) {
const [sorting, setSorting] = useState<SortingState>([
{ id: 'totalGames', desc: true },
])
const grouped = useMemo(
() => groupBy(games, (game) => game.opponentId),
[games]
)
const tableData: Stats[] = useMemo(
() =>
Object.values(grouped).map((gamesAgainstOpponent) => {
const totalGames = gamesAgainstOpponent.length
let wins = 0
let losses = 0
let totalMMRChange = 0
for (const game of gamesAgainstOpponent) {
totalMMRChange += game.mmrChange
if (game.mmrChange > 0) wins++
else if (game.mmrChange < 0) losses++
}
const stats: Stats = {
totalGames,
wins,
losses,
opponentName: gamesAgainstOpponent[0].opponentName,
opponentId: gamesAgainstOpponent[0].opponentId,
totalMMRChange,
winRate: (wins / totalGames) * 100,
}
return stats
}),
[grouped]
)
const columns = useColumns()
const table = useReactTable({
data: tableData,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (originalRow) => originalRow.opponentId,
})
return (
<div className='rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sortDirection = header.column.getIsSorted()
return (
<TableHead key={header.id} className={'px-0'}>
<span
className={cn(
'flex w-full items-center',
(header.column.columnDef.meta as any)?.className
)}
>
<Button
className={cn(
header.column.getCanSort() &&
'cursor-pointer select-none',
(
header.column.columnDef.meta as any
)?.className?.includes('justify-end') &&
'flex-row-reverse'
)}
size={'table'}
variant='ghost'
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{sortDirection ? (
<ArrowUp
className={cn(
'transition-transform',
sortDirection === 'desc' ? 'rotate-180' : ''
)}
/>
) : (
<div className={'h-4 w-4'} />
)}
</Button>
</span>
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
export const OpponentsTable = memo(RawOpponentsTable)
OpponentsTable.displayName = 'OpponentsTable'

View File

@@ -9,7 +9,8 @@ import {
import type React from 'react'
import { useState } from 'react'
import { GamesTable } from '@/app/players/[id]/_components/games-table'
import { GamesTable } from '@/app/(home)/players/[id]/_components/games-table'
import { OpponentsTable } from '@/app/(home)/players/[id]/_components/opponents-table'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import {
@@ -48,6 +49,7 @@ const dateFormatter = new Intl.DateTimeFormat('en-US', {
export function UserInfo() {
const [filter, setFilter] = useState('all')
const [leaderboardFilter, setLeaderboardFilter] = useState('all')
const { id } = useParams()
if (!id || typeof id !== 'string') return null
@@ -55,15 +57,14 @@ export function UserInfo() {
// Fetch games data unconditionally
const gamesQuery = api.history.user_games.useSuspenseQuery({ user_id: id })
const games = gamesQuery[0] || [] // Ensure games is always an array
const [discord_user] = api.discord.get_user_by_id.useSuspenseQuery({
user_id: id,
})
// Mock data for the two leaderboards - replace with actual API calls
const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({
channel_id: RANKED_CHANNEL,
})
const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery(
{
channel_id: VANILLA_CHANNEL,
@@ -78,11 +79,13 @@ export function UserInfo() {
user_id: id,
})
// Filter games by leaderboard if needed
const filteredGamesByLeaderboard =
leaderboardFilter === 'all'
? games
: games.filter((game) => game.gameType === leaderboardFilter)
: games.filter(
(game) =>
game.gameType.toLowerCase() === leaderboardFilter?.toLowerCase()
)
// Filter by result
const filteredGames =
@@ -332,6 +335,7 @@ export function UserInfo() {
<div className='mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center'>
<TabsList className='bg-gray-100 dark:bg-zinc-800'>
<TabsTrigger value='matches'>Match History</TabsTrigger>
<TabsTrigger value='opponents'>Opponents</TabsTrigger>
<TabsTrigger value='stats'>Statistics</TabsTrigger>
<TabsTrigger value='achievements'>Achievements</TabsTrigger>
</TabsList>
@@ -376,6 +380,13 @@ export function UserInfo() {
</div>
</div>
</TabsContent>
<TabsContent value='opponents' className='m-0'>
<div className='overflow-hidden rounded-lg border'>
<div className='overflow-x-auto'>
<OpponentsTable games={filteredGames} />
</div>
</div>
</TabsContent>
<TabsContent value='stats' className='m-0'>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>