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() {
+ +
+
+ +
+
+