stats reorg, upcoming refinement

This commit is contained in:
yohlo
2025-09-14 23:10:05 -05:00
parent 8efc0a7a4b
commit 9a105b30c6
18 changed files with 703 additions and 373 deletions

View File

@@ -33,6 +33,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2",
"redaxios": "^0.5.1",

View File

@@ -0,0 +1,211 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation694999214",
"maxSelect": 1,
"minSelect": 0,
"name": "team_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_ZNMy",
"max": 0,
"min": 0,
"name": "team_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number103159226",
"max": null,
"min": null,
"name": "matches",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json2732118329",
"maxSize": 1,
"name": "wins",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json724428801",
"maxSize": 1,
"name": "losses",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3041953980",
"maxSize": 1,
"name": "margin_of_victory",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1531431708",
"maxSize": 1,
"name": "margin_of_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1062535948",
"maxSize": 1,
"name": "total_cups_won_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json4249694556",
"maxSize": 1,
"name": "total_cups_lost_by",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3154249934",
"maxSize": 1,
"name": "total_cups_made",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3227208027",
"maxSize": 1,
"name": "total_cups_against",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_135889472",
"indexes": [],
"listRule": null,
"name": "team_stats",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": `
SELECT
t.id as id,
t.id as team_id,
t.name as team_name,
COUNT(m.id) as matches,
SUM(CASE
WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR
(m.away = t.id AND m.away_cups > m.home_cups)
THEN 1 ELSE 0
END) as wins,
SUM(CASE
WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR
(m.away = t.id AND m.away_cups < m.home_cups)
THEN 1 ELSE 0
END) as losses,
AVG(CASE
WHEN m.home = t.id AND m.home_cups > m.away_cups
THEN m.home_cups - m.away_cups
WHEN m.away = t.id AND m.away_cups > m.home_cups
THEN m.away_cups - m.home_cups
ELSE NULL
END) as margin_of_victory,
AVG(CASE
WHEN m.home = t.id AND m.home_cups < m.away_cups
THEN m.away_cups - m.home_cups
WHEN m.away = t.id AND m.away_cups < m.home_cups
THEN m.home_cups - m.away_cups
ELSE NULL
END) as margin_of_loss,
SUM(CASE
WHEN m.home = t.id THEN m.home_cups
WHEN m.away = t.id THEN m.away_cups
ELSE 0
END) as total_cups_won_by,
SUM(CASE
WHEN m.home = t.id THEN m.away_cups
WHEN m.away = t.id THEN m.home_cups
ELSE 0
END) as total_cups_lost_by,
SUM(CASE
WHEN m.home = t.id THEN m.home_cups
WHEN m.away = t.id THEN m.away_cups
ELSE 0
END) as total_cups_made,
SUM(CASE
WHEN m.home = t.id THEN m.away_cups
WHEN m.away = t.id THEN m.home_cups
ELSE 0
END) as total_cups_against
FROM teams t
JOIN matches m ON (m.home = t.id OR m.away = t.id)
JOIN tournaments tour ON m.tournament = tour.id
WHERE m.status = 'ended'
GROUP BY t.id`,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_135889472");
return app.delete(collection);
})

View File

@@ -20,6 +20,7 @@ import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
import { scan } from "react-scan";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
@@ -105,6 +106,12 @@ function RootComponent() {
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
React.useEffect(() => {
scan({
enabled: true,
});
}, []);
return (
<html
{...mantineHtmlProps}

View File

@@ -21,9 +21,8 @@ export const Route = createFileRoute("/_authed/")({
function Home() {
const { data: tournament } = useCurrentTournament();
const now = new Date();
if (new Date(tournament.start_time) > now) {
if (!tournament.matches || tournament.matches.length === 0) {
return <UpcomingTournament tournament={tournament} />;
}

View File

@@ -43,37 +43,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
direction: "desc",
});
// Calculate MMR (Match Making Rating) based on multiple factors
const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0;
// Base score from win percentage (0-100)
const winScore = stat.win_percentage;
// Match confidence factor (more matches = more reliable)
// Cap at 20 matches for full confidence
const matchConfidence = Math.min(stat.matches / 20, 1);
const matchConfidence = Math.min(stat.matches / 15, 1);
// Performance metrics
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
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; // Cap at 2.5 margin
: 0;
// Volume bonus for active players (small bonus for playing more)
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
const volumeBonus = Math.min(stat.matches * 0.5, 10);
// Weighted calculation
const baseMMR =
winScore * 0.5 + // Win % is 50% of score
avgCupsScore * 0.25 + // Avg cups is 25% of score
marginScore * 0.15 + // Win margin is 15% of score
volumeBonus * 0.1; // Volume bonus is 10% of score
winScore * 0.5 +
avgCupsScore * 0.25 +
marginScore * 0.15 +
volumeBonus * 0.1;
// Apply confidence factor (players with few matches get penalized)
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
return Math.round(finalMMR * 10) / 10;
};
const handleSort = (key: SortKey) => {
@@ -101,7 +93,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let aValue: number | string;
let bValue: number | string;
// Special handling for MMR
if (sortConfig.key === "mmr") {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
@@ -315,7 +306,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
</Text>
<Text size="xs" mt="xs" c="dimmed">
* Confidence penalty applied for players
with &lt;20 matches
with &lt;15 matches
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating

View File

@@ -1,11 +1,12 @@
import { Box } from "@mantine/core";
import Header from "./header";
import { Player } from "@/features/players/types";
import { Player, PlayerStats } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches } from "../../queries";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview";
import StatsOverview from "@/shared/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import { BaseStats } from "@/shared/types/stats";
interface ProfileProps {
id: string;
@@ -14,11 +15,24 @@ interface ProfileProps {
const Profile = ({ id }: ProfileProps) => {
const { data: player } = usePlayer(id);
const { data: matches } = usePlayerMatches(id);
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
// Aggregate player stats from multiple tournaments into a single BaseStats object
const aggregatedStats: BaseStats | null = stats && stats.length > 0 ? {
id: `player_${id}_aggregate`,
matches: stats.reduce((acc, stat) => acc + stat.matches, 0),
wins: stats.reduce((acc, stat) => acc + stat.wins, 0),
losses: stats.reduce((acc, stat) => acc + stat.losses, 0),
total_cups_made: stats.reduce((acc, stat) => acc + stat.total_cups_made, 0),
total_cups_against: stats.reduce((acc, stat) => acc + stat.total_cups_against, 0),
margin_of_victory: stats.filter(s => s.margin_of_victory > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_victory / arr.length, 0),
margin_of_loss: stats.filter(s => s.margin_of_loss > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_loss / arr.length, 0),
} : null;
const tabs = [
{
label: "Overview",
content: <StatsOverview playerId={id} />,
content: <StatsOverview statsData={aggregatedStats} isLoading={statsLoading} />,
},
{
label: "Matches",

View File

@@ -1,270 +0,0 @@
import { Box, Grid, Text, Group, Stack, ThemeIcon, Card, Avatar, Progress, Badge, Divider } from "@mantine/core";
import { CrownIcon, XIcon, FireIcon, ShieldIcon, ChartLineUpIcon, ShieldCheckIcon, BoxingGloveIcon, Icon, TrendUpIcon, ArrowUpIcon, ArrowDownIcon } from "@phosphor-icons/react";
import { usePlayerStats } from "../queries";
interface StatsOverviewProps {
playerId: string;
}
const StatCard = ({
label,
value,
suffix = "",
Icon,
variant = "default"
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
variant?: "default" | "compact";
}) => {
if (variant === "compact") {
return (
<Card p="xs" radius="md" withBorder>
<Group gap={2} justify="space-between" align="flex-start">
<Stack gap={2} flex={1}>
<Text size='xs' c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
<Text size="md" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="md">
<Icon size={12} />
</ThemeIcon>
)}
</Group>
</Card>
);
}
return (
<Card p="sm" radius="md" withBorder>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="sm">
<Icon size={14} />
</ThemeIcon>
)}
</Group>
<Text size="xl" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
</Card>
);
};
const StatsOverview = ({ playerId }: StatsOverviewProps) => {
const { data: statsData } = usePlayerStats(playerId);
if (!statsData || statsData.length === 0) {
return (
<Box p="sm">
<div style={{ padding: '12px', border: '1px solid var(--mantine-color-gray-3)', borderRadius: '4px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
No stats available yet
</Text>
</div>
</Box>
);
}
const overallStats = statsData.reduce(
(acc, stat) => ({
matches: acc.matches + stat.matches,
wins: acc.wins + stat.wins,
losses: acc.losses + stat.losses,
total_cups_made: acc.total_cups_made + stat.total_cups_made,
total_cups_against: acc.total_cups_against + stat.total_cups_against,
}),
{ matches: 0, wins: 0, losses: 0, total_cups_made: 0, total_cups_against: 0 }
);
const winPercentage = overallStats.matches > 0
? ((overallStats.wins / overallStats.matches) * 100)
: 0;
const avgCupsPerMatch = overallStats.matches > 0
? (overallStats.total_cups_made / overallStats.matches)
: 0;
const avgCupsAgainstPerMatch = overallStats.matches > 0
? (overallStats.total_cups_against / overallStats.matches)
: 0;
const validMarginOfVictory = statsData.filter(stat => stat.margin_of_victory > 0);
const validMarginOfLoss = statsData.filter(stat => stat.margin_of_loss > 0);
const avgMarginOfVictory = validMarginOfVictory.length > 0
? (validMarginOfVictory.reduce((acc, stat) => acc + stat.margin_of_victory, 0) / validMarginOfVictory.length)
: 0;
const avgMarginOfLoss = validMarginOfLoss.length > 0
? (validMarginOfLoss.reduce((acc, stat) => acc + stat.margin_of_loss, 0) / validMarginOfLoss.length)
: 0;
const getWinRateColor = (rate: number) => {
if (rate >= 70) return "green";
if (rate >= 50) return "blue";
if (rate >= 30) return "orange";
return "red";
};
return (
<Box>
<Stack gap="lg">
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Match Statistics</Text>
<Grid gutter="xs">
<Grid.Col span={4}>
<StatCard
label="Matches"
value={overallStats.matches}
Icon={BoxingGloveIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Wins"
value={overallStats.wins}
Icon={CrownIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Losses"
value={overallStats.losses}
Icon={XIcon}
variant="compact"
/>
</Grid.Col>
</Grid>
</Stack>
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Metrics</Text>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard
label="Cups Made"
value={overallStats.total_cups_made}
Icon={FireIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Cups Against"
value={overallStats.total_cups_against}
Icon={ShieldIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Per Game"
value={parseFloat(avgCupsPerMatch.toFixed(1))}
Icon={ChartLineUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Against"
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
Icon={ShieldCheckIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Win Margin"
value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null}
Icon={ArrowUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Loss Margin"
value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null}
Icon={ArrowDownIcon}
/>
</Grid.Col>
</Grid>
</Stack>
{/* Team Performance */}
{statsData.length > 1 && (
<>
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Team Performance</Text>
<Stack gap="xs">
{statsData.map((stat) => {
const teamWinRate = (stat.wins / stat.matches) * 100;
return (
<Card key={stat.id} p="md" radius="md" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Avatar
size="md"
color={getWinRateColor(teamWinRate)}
radius="lg"
>
{stat.player_name.split(' ').map(n => n[0]).join('')}
</Avatar>
<Stack gap={2}>
<Text size="sm" fw={600}>{stat.player_name}</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
{stat.matches} matches
</Text>
<Text size="xs" c="dimmed"></Text>
<Text size="xs" c="green.6">
{stat.wins}W
</Text>
<Text size="xs" c="red.6">
{stat.losses}L
</Text>
</Group>
</Stack>
</Group>
<Stack gap={4} align="flex-end">
<Badge
size="sm"
variant="light"
color={getWinRateColor(teamWinRate)}
>
{teamWinRate.toFixed(0)}%
</Badge>
<Progress
value={teamWinRate}
size="xs"
w={60}
color={getWinRateColor(teamWinRate)}
/>
</Stack>
</Group>
</Card>
);
})}
</Stack>
</Stack>
</>
)}
</Stack>
</Box>
);
};
export default StatsOverview;

View File

@@ -2,7 +2,8 @@ import { Box, Text } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import { useTeam } from "../../queries";
import StatsOverview from "@/shared/components/stats-overview";
import { useTeam, useTeamStats } from "../../queries";
interface ProfileProps {
id: string;
@@ -10,12 +11,13 @@ interface ProfileProps {
const TeamProfile = ({ id }: ProfileProps) => {
const { data: team } = useTeam(id);
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
if (!team) return <Text p="md">Team not found</Text>;
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text>,
content: <StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} />,
},
{
label: "Matches",

View File

@@ -1,8 +1,9 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getTeam } from "./server";
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
import { getTeam, getTeamStats } from "./server";
export const teamKeys = {
details: (id: string) => ['teams', 'details', id] as const,
stats: (id: string) => ['teams', 'stats', id] as const,
};
export const teamQueries = {
@@ -10,7 +11,18 @@ export const teamQueries = {
queryKey: teamKeys.details(id),
queryFn: () => getTeam({ data: id }),
}),
stats: (id: string) => ({
queryKey: teamKeys.stats(id),
queryFn: () => getTeamStats({ data: id }),
}),
};
export const useTeam = (id: string) =>
useServerSuspenseQuery(teamQueries.details(id));
export const useTeamStats = (id: string) =>
useServerQuery({
...teamQueries.stats(id),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});

View File

@@ -50,7 +50,7 @@ export const updateTeam = createServerFn()
updates: teamUpdateSchema
}))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { id, updates }, context }) =>
.handler(async ({ data: { id, updates }, context }) =>
toServerResult(async () => {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
@@ -71,3 +71,10 @@ export const updateTeam = createServerFn()
return pbAdmin.updateTeam(id, updates);
})
);
export const getTeamStats = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamStats(teamId))
);

View File

@@ -96,3 +96,16 @@ export const teamUpdateSchema = z
export type TeamInput = z.infer<typeof teamInputSchema>;
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
export interface TeamStats {
id: string;
team_id: string;
team_name: string;
matches: number;
wins: number;
losses: number;
total_cups_made: number;
total_cups_against: number;
margin_of_victory: number;
margin_of_loss: number;
}

View File

@@ -0,0 +1,72 @@
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
import { Tournament } from "../../types";
import Avatar from "@/components/avatar";
import { CalendarIcon, MapPinIcon, TrophyIcon, UsersIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
const Header = ({ tournament }: { tournament: Tournament}) => {
const tournamentStart = useMemo(() => new Date(tournament.start_time), [tournament.start_time]);
const teamCount = useMemo(() => tournament.teams?.length || 0, [tournament.teams]);
return (
<Group justify="space-around" align="flex-start" w='100%'>
<Group align="center" gap="lg">
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={200}
px='md'
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
<Stack gap="xs">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<UsersIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{teamCount} teams enrolled
</Text>
</Group>
</Stack>
</Group>
</Group>
)
}
export default Header;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Tournament } from "../types";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import {
Box,
@@ -23,8 +23,10 @@ import {
UsersIcon,
ListIcon,
} from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import EnrollTeam from "../enroll-team";
import EnrollFreeAgent from "../enroll-free-agent";
import TeamListButton from "./team-list-button";
import Header from "./header";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
@@ -44,70 +46,12 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const tournamentStart = new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const enrolledTeamsCount = tournament.teams?.length || 0;
return (
<Stack gap="lg">
<Group justify="space-around" align="flex-start" w='100%'>
<Group align="center" gap="lg">
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={120}
px='md'
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
<Stack gap="xs">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">
<UsersIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} teams enrolled
</Text>
</Group>
</Stack>
</Group>
</Group>
<Header tournament={tournament} />
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
@@ -149,11 +93,7 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
/>
)}
<ListButton label="View Rules" Icon={ListIcon} onClick={() => {}} />
<ListButton
label={`View Teams (${enrolledTeamsCount})`}
Icon={UsersIcon}
onClick={() => {}}
/>
<TeamListButton teams={tournament.teams || []} />
</Box>
</Stack>
);

View File

@@ -0,0 +1,31 @@
import ListButton from "@/components/list-button"
import Sheet from "@/components/sheet/sheet"
import TeamList from "@/features/teams/components/team-list"
import { TeamInfo } from "@/features/teams/types"
import { useSheet } from "@/hooks/use-sheet"
import { UsersIcon } from "@phosphor-icons/react"
import { useMemo } from "react"
interface TeamListButtonProps {
teams: TeamInfo[]
}
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
const count = useMemo(() => teams.length, [teams]);
const { open, isOpen, toggle } = useSheet();
return (
<>
<ListButton
label={`View Teams (${count})`}
Icon={UsersIcon}
onClick={open}
/>
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
<TeamList teams={teams} />
</Sheet>
</>
)
}
export default TeamListButton;

View File

@@ -1,7 +1,7 @@
import { logger } from "@/lib/logger";
import PocketBase from "pocketbase";
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
import { Team, TeamInfo, TeamInput, TeamUpdateInput } from "@/features/teams/types";
import { Team, TeamInfo, TeamInput, TeamUpdateInput, TeamStats } from "@/features/teams/types";
export function createTeamsService(pb: PocketBase) {
return {
@@ -64,7 +64,7 @@ export function createTeamsService(pb: PocketBase) {
async updateTeam(id: string, data: TeamUpdateInput): Promise<Team> {
logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
try {
const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
if (!existingTeam) {
@@ -72,7 +72,7 @@ export function createTeamsService(pb: PocketBase) {
}
const result = await pb.collection("teams").update(id, data);
return transformTeam(await pb.collection("teams").getOne(result.id, {
expand: "players, tournaments"
}));
@@ -81,5 +81,16 @@ export function createTeamsService(pb: PocketBase) {
throw error;
}
},
async getTeamStats(id: string): Promise<TeamStats | null> {
logger.info("PocketBase | Getting team stats", id);
try {
const result = await pb.collection("team_stats").getFirstListItem(`team_id="${id}"`);
return result as TeamStats;
} catch (error) {
logger.info("PocketBase | No team stats found", id);
return null;
}
},
};
}

View File

@@ -0,0 +1,281 @@
import {
Box,
Grid,
Text,
Group,
Stack,
ThemeIcon,
Card,
Avatar,
Progress,
Badge,
Divider,
Skeleton,
} from "@mantine/core";
import {
CrownIcon,
XIcon,
FireIcon,
ShieldIcon,
ChartLineUpIcon,
ShieldCheckIcon,
BoxingGloveIcon,
Icon,
TrendUpIcon,
ArrowUpIcon,
ArrowDownIcon,
} from "@phosphor-icons/react";
import { BaseStats } from "@/shared/types/stats";
interface StatsOverviewProps {
statsData: BaseStats | null;
isLoading?: boolean;
}
const StatCard = ({
label,
value,
suffix = "",
Icon,
variant = "default",
isLoading = false,
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
variant?: "default" | "compact";
isLoading?: boolean;
}) => {
if (variant === "compact") {
return (
<Card p="xs" radius="md" withBorder>
<Group gap={2} justify="space-between" align="flex-start">
<Stack gap={2} flex={1}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
{isLoading ? (
<Skeleton height={20} width="60%" />
) : (
<Text size="md" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
)}
</Stack>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="md">
<Icon size={12} />
</ThemeIcon>
)}
</Group>
</Card>
);
}
return (
<Card p="sm" radius="md" withBorder>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="sm">
<Icon size={14} />
</ThemeIcon>
)}
</Group>
{isLoading ? (
<Skeleton height={28} width="70%" />
) : (
<Text size="xl" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
)}
</Stack>
</Card>
);
};
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
// Show skeleton loading state
if (isLoading || (!statsData && isLoading)) {
return (
<Box p="sm" h="auto" mih={400}>
<Stack gap="lg">
<Stack gap="sm">
<Skeleton height={20} width="40%" />
<Grid gutter="xs">
<Grid.Col span={4}>
<StatCard label="Matches" value={null} Icon={BoxingGloveIcon} variant="compact" isLoading />
</Grid.Col>
<Grid.Col span={4}>
<StatCard label="Wins" value={null} Icon={CrownIcon} variant="compact" isLoading />
</Grid.Col>
<Grid.Col span={4}>
<StatCard label="Losses" value={null} Icon={XIcon} variant="compact" isLoading />
</Grid.Col>
</Grid>
</Stack>
<Stack gap="sm">
<Skeleton height={20} width="30%" />
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard label="Cups Made" value={null} Icon={FireIcon} isLoading />
</Grid.Col>
<Grid.Col span={6}>
<StatCard label="Cups Against" value={null} Icon={ShieldIcon} isLoading />
</Grid.Col>
<Grid.Col span={6}>
<StatCard label="Avg Per Game" value={null} Icon={ChartLineUpIcon} isLoading />
</Grid.Col>
<Grid.Col span={6}>
<StatCard label="Avg Against" value={null} Icon={ShieldCheckIcon} isLoading />
</Grid.Col>
<Grid.Col span={6}>
<StatCard label="Win Margin" value={null} Icon={ArrowUpIcon} isLoading />
</Grid.Col>
<Grid.Col span={6}>
<StatCard label="Loss Margin" value={null} Icon={ArrowDownIcon} isLoading />
</Grid.Col>
</Grid>
</Stack>
</Stack>
</Box>
);
}
// Show no data state only when we know for sure there's no data
if (!statsData && !isLoading) {
return (
<Box p="sm" h="auto" mih={200}>
<Text ta="center" size="sm" fw={600} c="dimmed">
No stats available yet
</Text>
</Box>
);
}
if (!statsData) return null;
const overallStats = {
matches: statsData.matches,
wins: statsData.wins,
losses: statsData.losses,
total_cups_made: statsData.total_cups_made,
total_cups_against: statsData.total_cups_against,
};
const avgCupsPerMatch =
overallStats.matches > 0
? overallStats.total_cups_made / overallStats.matches
: 0;
const avgCupsAgainstPerMatch =
overallStats.matches > 0
? overallStats.total_cups_against / overallStats.matches
: 0;
const avgMarginOfVictory = statsData.margin_of_victory || 0;
const avgMarginOfLoss = statsData.margin_of_loss || 0;
return (
<Box p="sm" h="auto" mih={400}>
<Stack gap="lg">
<Stack gap="sm">
<Text size="md" fw={600} c="dark">
Match Statistics
</Text>
<Grid gutter="xs">
<Grid.Col span={4}>
<StatCard
label="Matches"
value={overallStats.matches}
Icon={BoxingGloveIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Wins"
value={overallStats.wins}
Icon={CrownIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Losses"
value={overallStats.losses}
Icon={XIcon}
variant="compact"
/>
</Grid.Col>
</Grid>
</Stack>
<Stack gap="sm">
<Text size="md" fw={600} c="dark">
Metrics
</Text>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard
label="Cups Made"
value={overallStats.total_cups_made}
Icon={FireIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Cups Against"
value={overallStats.total_cups_against}
Icon={ShieldIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Per Game"
value={parseFloat(avgCupsPerMatch.toFixed(1))}
Icon={ChartLineUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Against"
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
Icon={ShieldCheckIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Win Margin"
value={
avgMarginOfVictory > 0
? parseFloat(avgMarginOfVictory.toFixed(1))
: null
}
Icon={ArrowUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Loss Margin"
value={
avgMarginOfLoss > 0
? parseFloat(avgMarginOfLoss.toFixed(1))
: null
}
Icon={ArrowDownIcon}
/>
</Grid.Col>
</Grid>
</Stack>
</Stack>
</Box>
);
};
export default StatsOverview;

10
src/shared/types/stats.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface BaseStats {
id: string;
matches: number;
wins: number;
losses: number;
total_cups_made: number;
total_cups_against: number;
margin_of_victory: number;
margin_of_loss: number;
}

View File

@@ -24,7 +24,6 @@ export const verifySuperTokensSession = async (
logger.info("Session needs refresh");
try {
// attempt refresh on backend
if (response) {
const refreshedSession = await refreshSession(request, response);
if (refreshedSession) {
@@ -33,7 +32,6 @@ export const verifySuperTokensSession = async (
}
if (session?.needsRefresh) {
// tryRefresh on frontend
return { context: { session: { tryRefresh: true } } };
}
} catch (error: any) {