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
- {filteredAndSortedStats.map((stat, index) => (
-
-
- {index < filteredAndSortedStats.length - 1 && }
-
- ))}
+ (
+
+ {index > 0 && }
+
+
+ )}
+ loader={
+ <>
+
+
+
+
+ >
+ }
+ />
{filteredAndSortedStats.length === 0 && search && (