diff --git a/src/app/(home)/log-parser/page.tsx b/src/app/(home)/log-parser/page.tsx
index 830076b..19b2b64 100644
--- a/src/app/(home)/log-parser/page.tsx
+++ b/src/app/(home)/log-parser/page.tsx
@@ -417,52 +417,58 @@ export default function LogParser() {
))}
- {moneyReports.map((report, i) => (
- // biome-ignore lint/suspicious/noArrayIndexKey:
-
-
Game {i + 1}
-
-
-
-
- Shop #
-
-
- Logs owner
-
-
- Opponent
-
-
-
-
- {report.spentPerShop.map((spent, j) => (
- // biome-ignore lint/suspicious/noArrayIndexKey:
-
+ {moneyReports.map((report, i) => {
+ const mostShops =
+ report.spentPerShop.length > report.spentPerShopOpponent.length
+ ? report.spentPerShop.length
+ : report.spentPerShopOpponent.length
+ return (
+ // biome-ignore lint/suspicious/noArrayIndexKey:
+
+
Game {i + 1}
+
+
+
+
+ Shop #
+
+
+ Logs owner
+
+
+ Opponent
+
+
+
+
+ {Array.from({ length: mostShops }).map((_, j) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey:
+
+
+ {j + 1}
+
+
+ {report.spentPerShop[j] ?? 'Skipped'}
+
+
+ {report.spentPerShopOpponent[j] ?? 'Skipped'}
+
+
+ ))}
+
+ Total
- {j + 1}
+ {report.totalSpent}
- {spent ?? 'Skipped'}
-
-
- {report.spentPerShopOpponent[j] ?? 'Skipped'}
+ {report.totalSpentOpponent}
- ))}
-
- Total
-
- {report.totalSpent}
-
-
- {report.totalSpentOpponent}
-
-
-
-
-
- ))}
+
+
+
+ )
+ })}
diff --git a/src/app/(home)/players/[id]/_components/opponents-table.tsx b/src/app/(home)/players/[id]/_components/opponents-table.tsx
new file mode 100644
index 0000000..b0652f0
--- /dev/null
+++ b/src/app/(home)/players/[id]/_components/opponents-table.tsx
@@ -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()
+
+const useColumns = () => {
+ return useMemo(
+ () => [
+ columnHelper.accessor('opponentName', {
+ meta: { className: 'pl-4' },
+ header: 'Opponent',
+ cell: (info) => (
+
+ {info.getValue()}
+
+ ),
+ }),
+ columnHelper.accessor('totalGames', {
+ header: 'Games Played',
+ meta: { className: 'justify-end' },
+ cell: (info) => {
+ const totalGames = info.getValue()
+ return (
+
+ {totalGames}
+
+ )
+ },
+ }),
+
+ columnHelper.accessor('wins', {
+ header: 'Wins',
+ meta: { className: 'justify-end' },
+ cell: (info) => {
+ const wins = info.getValue()
+ return (
+ {wins}
+ )
+ },
+ }),
+ columnHelper.accessor('losses', {
+ header: 'Losses',
+ meta: { className: 'justify-end' },
+ cell: (info) => {
+ const losses = info.getValue()
+ return (
+ {losses}
+ )
+ },
+ }),
+ columnHelper.accessor('winRate', {
+ header: 'Win rate',
+ meta: { className: 'justify-end' },
+ cell: (info) => {
+ const winRate = info.getValue()
+ return (
+ 50
+ ? 'text-emerald-500'
+ : 'text-rose-500'
+ )}
+ >
+ {Math.round(winRate)}%
+
+ )
+ },
+ }),
+ columnHelper.accessor('totalMMRChange', {
+ header: 'Total MMR change',
+ meta: { className: 'justify-end' },
+ cell: (info) => {
+ const mmrChange = info.getValue()
+ return (
+ 0
+ ? 'text-emerald-500'
+ : 'text-rose-500'
+ )}
+ >
+ {numberFormatter.format(Math.trunc(mmrChange))}
+ {mmrChange === 0 ? (
+
+ ) : mmrChange > 0 ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
+ }),
+ ],
+ []
+ )
+}
+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([
+ { 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 (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const sortDirection = header.column.getIsSorted()
+ return (
+
+
+
+
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+export const OpponentsTable = memo(RawOpponentsTable)
+OpponentsTable.displayName = 'OpponentsTable'
diff --git a/src/app/(home)/players/[id]/user.tsx b/src/app/(home)/players/[id]/user.tsx
index b90e06f..5d06fd9 100644
--- a/src/app/(home)/players/[id]/user.tsx
+++ b/src/app/(home)/players/[id]/user.tsx
@@ -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() {
Match History
+ Opponents
Statistics
Achievements
@@ -376,6 +380,13 @@ export function UserInfo() {
+
+
+