From f77b3a5c41da5bbe571b79c949d293c58948b8f6 Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 13 Jul 2026 23:57:31 -0700 Subject: [PATCH] infinite scroll --- ...1783926000_optimized_player_stats_views.js | 50 ++++++++++ src/app/routes/_authed/stats.tsx | 14 ++- src/components/infinite-scroll.tsx | 73 +++++++++++++++ .../player-stats-table-skeleton.tsx | 2 +- .../players/components/player-stats-table.tsx | 93 ++++++++++++------- 5 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 pb_migrations/1783926000_optimized_player_stats_views.js create mode 100644 src/components/infinite-scroll.tsx diff --git a/pb_migrations/1783926000_optimized_player_stats_views.js b/pb_migrations/1783926000_optimized_player_stats_views.js new file mode 100644 index 0000000..4757a3c --- /dev/null +++ b/pb_migrations/1783926000_optimized_player_stats_views.js @@ -0,0 +1,50 @@ +/// +migrate((app) => { + { + const collection = app.findCollectionByNameOrId("player_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id" + }, collection); + app.save(collection); + } + + { + const collection = app.findCollectionByNameOrId("player_mainline_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id" + }, collection); + app.save(collection); + } + + { + const collection = app.findCollectionByNameOrId("player_regional_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id" + }, collection); + app.save(collection); + } +}, (app) => { + { + const collection = app.findCollectionByNameOrId("player_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id" + }, collection); + app.save(collection); + } + + { + const collection = app.findCollectionByNameOrId("player_mainline_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id" + }, collection); + app.save(collection); + } + + { + const collection = app.findCollectionByNameOrId("player_regional_stats"); + unmarshal({ + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id" + }, collection); + app.save(collection); + } +}) diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index ff0faae..5533770 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -1,19 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { playerQueries } from "@/features/players/queries"; import PlayerStatsTable from "@/features/players/components/player-stats-table"; -import { Suspense, useState, useDeferredValue } from "react"; +import { Suspense, useState, useDeferredValue, useEffect } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core"; +import { useQueryClient } from "@tanstack/react-query"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, beforeLoad: ({ context }) => { const queryClient = context.queryClient; prefetchServerQuery(queryClient, playerQueries.allStats('all')); - prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); - prefetchServerQuery(queryClient, playerQueries.allStats('regional')); }, loader: () => ({ withPadding: false, @@ -29,6 +28,15 @@ function Stats() { const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); const deferredViewType = useDeferredValue(viewType); const isStale = viewType !== deferredViewType; + const queryClient = useQueryClient(); + + useEffect(() => { + const timeout = window.setTimeout(() => { + prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); + prefetchServerQuery(queryClient, playerQueries.allStats('regional')); + }, 1500); + return () => window.clearTimeout(timeout); + }, [queryClient]); return ( diff --git a/src/components/infinite-scroll.tsx b/src/components/infinite-scroll.tsx new file mode 100644 index 0000000..d8f3935 --- /dev/null +++ b/src/components/infinite-scroll.tsx @@ -0,0 +1,73 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { Box } from "@mantine/core"; + +interface InfiniteScrollProps { + items: T[]; + renderItem: (item: T, index: number) => ReactNode; + batchSize?: number; + initialCount?: number; + rootMargin?: string; + loader?: ReactNode; + hasMore?: boolean; + loading?: boolean; + onLoadMore?: () => void; +} + +const InfiniteScroll = ({ + items, + renderItem, + batchSize = 25, + initialCount = batchSize, + rootMargin = "600px 0px", + loader, + hasMore = false, + loading = false, + onLoadMore, +}: InfiniteScrollProps) => { + const [visibleCount, setVisibleCount] = useState(initialCount); + const [prevItems, setPrevItems] = useState(items); + const sentinelRef = useRef(null); + + if (prevItems !== items) { + setPrevItems(items); + setVisibleCount(initialCount); + } + + const hasHiddenItems = visibleCount < items.length; + const showLoader = hasHiddenItems || hasMore || loading; + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + + if (visibleCount < items.length) { + setVisibleCount((count) => Math.min(count + batchSize, items.length)); + } else if (hasMore && !loading) { + onLoadMore?.(); + } + }, + { rootMargin } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [items, visibleCount, batchSize, hasMore, loading, onLoadMore, rootMargin]); + + return ( + <> + {items.slice(0, visibleCount).map((item, index) => renderItem(item, index))} + {showLoader && ( + <> + + {loader} + + )} + + ); +}; + +export default InfiniteScroll; diff --git a/src/features/players/components/player-stats-table-skeleton.tsx b/src/features/players/components/player-stats-table-skeleton.tsx index 3d02616..e8d3af2 100644 --- a/src/features/players/components/player-stats-table-skeleton.tsx +++ b/src/features/players/components/player-stats-table-skeleton.tsx @@ -8,7 +8,7 @@ import { ScrollArea, } from "@mantine/core"; -const PlayerListItemSkeleton = () => { +export const PlayerListItemSkeleton = () => { return ( diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 880ee3e..b5de60a 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react"; +import { useState, useMemo, useCallback, memo, useRef, useEffect, useDeferredValue } from "react"; import { Text, TextInput, @@ -23,8 +23,10 @@ import { } from "@phosphor-icons/react"; import { PlayerStats } from "../types"; import PlayerAvatar from "@/components/player-avatar"; +import InfiniteScroll from "@/components/infinite-scroll"; import { useNavigate } from "@tanstack/react-router"; import { useAllPlayerStats } from "../queries"; +import { PlayerListItemSkeleton } from "./player-stats-table-skeleton"; type SortKey = keyof PlayerStats | "mmr"; type SortDirection = "asc" | "desc"; @@ -146,10 +148,32 @@ interface PlayerStatsTableProps { viewType?: 'all' | 'mainline' | 'regional'; } +const calculateMMR = (stat: PlayerStats): number => { + if (stat.matches === 0) return 0; + + const winScore = stat.win_percentage; + const matchConfidence = Math.min(stat.matches / 15, 1); + const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); + const marginScore = stat.margin_of_victory + ? Math.min(stat.margin_of_victory * 20, 50) + : 0; + const volumeBonus = Math.min(stat.matches * 0.5, 10); + + const baseMMR = + winScore * 0.5 + + avgCupsScore * 0.25 + + marginScore * 0.15 + + volumeBonus * 0.1; + + const finalMMR = baseMMR * matchConfidence; + return Math.round(finalMMR * 10) / 10; +}; + const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { const { data: playerStats } = useAllPlayerStats(viewType); const navigate = useNavigate(); const [search, setSearch] = useState(""); + const deferredSearch = useDeferredValue(search); const [sortConfig, setSortConfig] = useState({ key: "mmr" as SortKey, direction: "desc", @@ -159,10 +183,15 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { const scrollHandlersRef = useRef void>>(new Map()); const scrollLeaderRef = useRef(null); const scrollTimeoutRef = useRef(null); + const lastScrollLeftRef = useRef(0); const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => { viewportsRef.current.add(viewport); + if (lastScrollLeftRef.current > 0) { + viewport.scrollLeft = lastScrollLeftRef.current; + } + const handleScrollStart = () => { scrollLeaderRef.current = viewport; }; @@ -179,6 +208,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { } const scrollLeft = target.scrollLeft; + lastScrollLeftRef.current = scrollLeft; viewportsRef.current.forEach((vp) => { if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { @@ -213,27 +243,6 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { } }, []); - const calculateMMR = (stat: PlayerStats): number => { - if (stat.matches === 0) return 0; - - const winScore = stat.win_percentage; - const matchConfidence = Math.min(stat.matches / 15, 1); - const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); - const marginScore = stat.margin_of_victory - ? Math.min(stat.margin_of_victory * 20, 50) - : 0; - const volumeBonus = Math.min(stat.matches * 0.5, 10); - - const baseMMR = - winScore * 0.5 + - avgCupsScore * 0.25 + - marginScore * 0.15 + - volumeBonus * 0.1; - - const finalMMR = baseMMR * matchConfidence; - return Math.round(finalMMR * 10) / 10; - }; - const statsWithMMR = useMemo(() => { return playerStats.map((stat) => ({ ...stat, @@ -243,7 +252,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { const filteredAndSortedStats = useMemo(() => { let filtered = statsWithMMR.filter((stat) => - stat.player_name.toLowerCase().includes(search.toLowerCase()) + stat.player_name.toLowerCase().includes(deferredSearch.toLowerCase()) ); return filtered.sort((a, b) => { @@ -272,7 +281,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { return 0; }); - }, [statsWithMMR, search, sortConfig]); + }, [statsWithMMR, deferredSearch, sortConfig]); const handlePlayerClick = useCallback((playerId: string) => { navigate({ to: `/profile/${playerId}` }); @@ -433,18 +442,30 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { - {filteredAndSortedStats.map((stat, index) => ( - - - {index < filteredAndSortedStats.length - 1 && } - - ))} + ( + + {index > 0 && } + + + )} + loader={ + <> + + + + + + } + /> {filteredAndSortedStats.length === 0 && search && (