Merge branch 'loader' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s

This commit is contained in:
yohlo
2026-07-14 15:00:50 -07:00
4 changed files with 142 additions and 40 deletions
+11 -3
View File
@@ -1,19 +1,18 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; 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 PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core"; import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; const queryClient = context.queryClient;
prefetchServerQuery(queryClient, playerQueries.allStats('all')); prefetchServerQuery(queryClient, playerQueries.allStats('all'));
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
}, },
loader: () => ({ loader: () => ({
withPadding: false, withPadding: false,
@@ -29,6 +28,15 @@ function Stats() {
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
const deferredViewType = useDeferredValue(viewType); const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType; 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 ( return (
<Tabs defaultValue="stats"> <Tabs defaultValue="stats">
+73
View File
@@ -0,0 +1,73 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Box } from "@mantine/core";
interface InfiniteScrollProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
batchSize?: number;
initialCount?: number;
rootMargin?: string;
loader?: ReactNode;
hasMore?: boolean;
loading?: boolean;
onLoadMore?: () => void;
}
const InfiniteScroll = <T,>({
items,
renderItem,
batchSize = 25,
initialCount = batchSize,
rootMargin = "600px 0px",
loader,
hasMore = false,
loading = false,
onLoadMore,
}: InfiniteScrollProps<T>) => {
const [visibleCount, setVisibleCount] = useState(initialCount);
const [prevItems, setPrevItems] = useState(items);
const sentinelRef = useRef<HTMLDivElement>(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 && (
<>
<Box ref={sentinelRef} />
{loader}
</>
)}
</>
);
};
export default InfiniteScroll;
@@ -8,7 +8,7 @@ import {
ScrollArea, ScrollArea,
} from "@mantine/core"; } from "@mantine/core";
const PlayerListItemSkeleton = () => { export const PlayerListItemSkeleton = () => {
return ( return (
<Box p="md"> <Box p="md">
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}> <Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react"; import { useState, useMemo, useCallback, memo, useRef, useEffect, useDeferredValue } from "react";
import { import {
Text, Text,
TextInput, TextInput,
@@ -23,8 +23,10 @@ import {
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { PlayerStats } from "../types"; import { PlayerStats } from "../types";
import PlayerAvatar from "@/components/player-avatar"; import PlayerAvatar from "@/components/player-avatar";
import InfiniteScroll from "@/components/infinite-scroll";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
type SortKey = keyof PlayerStats | "mmr"; type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -146,10 +148,32 @@ interface PlayerStatsTableProps {
viewType?: 'all' | 'mainline' | 'regional'; 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 PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const { data: playerStats } = useAllPlayerStats(viewType); const { data: playerStats } = useAllPlayerStats(viewType);
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey, key: "mmr" as SortKey,
direction: "desc", direction: "desc",
@@ -159,10 +183,15 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map()); const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
const scrollLeaderRef = useRef<HTMLDivElement | null>(null); const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
const scrollTimeoutRef = useRef<number | null>(null); const scrollTimeoutRef = useRef<number | null>(null);
const lastScrollLeftRef = useRef(0);
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => { const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
viewportsRef.current.add(viewport); viewportsRef.current.add(viewport);
if (lastScrollLeftRef.current > 0) {
viewport.scrollLeft = lastScrollLeftRef.current;
}
const handleScrollStart = () => { const handleScrollStart = () => {
scrollLeaderRef.current = viewport; scrollLeaderRef.current = viewport;
}; };
@@ -179,6 +208,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
} }
const scrollLeft = target.scrollLeft; const scrollLeft = target.scrollLeft;
lastScrollLeftRef.current = scrollLeft;
viewportsRef.current.forEach((vp) => { viewportsRef.current.forEach((vp) => {
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { 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(() => { const statsWithMMR = useMemo(() => {
return playerStats.map((stat) => ({ return playerStats.map((stat) => ({
...stat, ...stat,
@@ -243,7 +252,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const filteredAndSortedStats = useMemo(() => { const filteredAndSortedStats = useMemo(() => {
let filtered = statsWithMMR.filter((stat) => let filtered = statsWithMMR.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase()) stat.player_name.toLowerCase().includes(deferredSearch.toLowerCase())
); );
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
@@ -272,7 +281,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
return 0; return 0;
}); });
}, [statsWithMMR, search, sortConfig]); }, [statsWithMMR, deferredSearch, sortConfig]);
const handlePlayerClick = useCallback((playerId: string) => { const handlePlayerClick = useCallback((playerId: string) => {
navigate({ to: `/profile/${playerId}` }); navigate({ to: `/profile/${playerId}` });
@@ -433,18 +442,30 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
</Group> </Group>
<Stack gap={0}> <Stack gap={0}>
{filteredAndSortedStats.map((stat, index) => ( <InfiniteScroll
<Box key={stat.id}> items={filteredAndSortedStats}
<PlayerListItem batchSize={25}
stat={stat} renderItem={(stat, index) => (
onPlayerClick={handlePlayerClick} <Box key={stat.id}>
mmr={stat.mmr} {index > 0 && <Divider />}
onRegisterViewport={handleRegisterViewport} <PlayerListItem
onUnregisterViewport={handleUnregisterViewport} stat={stat}
/> onPlayerClick={handlePlayerClick}
{index < filteredAndSortedStats.length - 1 && <Divider />} mmr={stat.mmr}
</Box> onRegisterViewport={handleRegisterViewport}
))} onUnregisterViewport={handleUnregisterViewport}
/>
</Box>
)}
loader={
<>
<Divider />
<PlayerListItemSkeleton />
<Divider />
<PlayerListItemSkeleton />
</>
}
/>
</Stack> </Stack>
{filteredAndSortedStats.length === 0 && search && ( {filteredAndSortedStats.length === 0 && search && (