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 && (