player stats in profile

This commit is contained in:
yohlo
2025-09-13 11:21:05 -05:00
parent 7d3c0a3fa4
commit 3fe92be980
7 changed files with 243 additions and 17 deletions

View File

@@ -25,19 +25,19 @@ export const Bracket: React.FC<BracketProps> = ({
justify="space-around"
p={24}
>
{round
.filter((match) => !match.bye)
.map((match) => {
return (
<div key={match.lid}>
<MatchCard
match={match}
orders={orders}
showControls={showControls}
/>
</div>
);
})}
{round.map((match) =>
match.bye ? (
<div key={match.lid}></div>
) : (
<div key={match.lid}>
<MatchCard
match={match}
orders={orders}
showControls={showControls}
/>
</div>
)
)}
</Flex>
))}
</Flex>

View File

@@ -4,6 +4,7 @@ import { Player } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview";
interface ProfileProps {
id: string;
@@ -14,7 +15,7 @@ const Profile = ({ id }: ProfileProps) => {
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text>,
content: <StatsOverview playerId={id} />,
},
{
label: "Matches",

View File

@@ -0,0 +1,187 @@
import { Box, Grid, Text, Group, Stack, ThemeIcon, Card, Avatar } from "@mantine/core";
import { TrophyIcon, CrownIcon, XIcon, FireIcon, ShieldIcon, ChartLineUpIcon, ShieldCheckIcon, BoxingGloveIcon, Icon } from "@phosphor-icons/react";
import { usePlayerStats } from "../queries";
interface StatsOverviewProps {
playerId: string;
}
const StatCard = ({
label,
value,
suffix = "",
Icon
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
}) => (
<Card p='xs'>
<Group justify="space-between" align="center" gap="xs">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Text size="sm" fw={600}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</div>
{Icon && (
<ThemeIcon size="sm" variant="subtle" c="blue">
<Icon size={14} />
</ThemeIcon>
)}
</Group>
</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>
);
}
// Aggregate stats across all teams
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;
// Calculate average margins from individual team stats
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;
return (
<Box p="sm">
<Stack gap="xs">
<Text size="md" fw={600} mb="xs">Stats</Text>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard
label="Matches"
value={overallStats.matches}
Icon={BoxingGloveIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Win Rate"
value={parseFloat(winPercentage.toFixed(1))}
suffix="%"
Icon={TrophyIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Wins"
value={overallStats.wins}
Icon={CrownIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Losses"
value={overallStats.losses}
Icon={XIcon}
/>
</Grid.Col>
<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>
{/* Team Breakdown */}
{statsData.length > 1 && (
<div>
<Text size="md" fw={600} mb="xs">Teams</Text>
<Stack gap={4}>
{statsData.map((stat) => (
<div key={stat.id} style={{ padding: '8px', border: '1px solid var(--mantine-color-gray-3)', borderRadius: '4px' }}>
<Group justify="space-between">
<Group gap="xs">
<Avatar size="xs" color="blue">
{stat.player_name.split(' ').map(n => n[0]).join('')}
</Avatar>
<div>
<Text size="xs" fw={500}>{stat.player_name}</Text>
<Text size="xs" c="dimmed">
{stat.matches}M {stat.wins}W - {stat.losses}L
</Text>
</div>
</Group>
<Text size="xs" fw={600}>
{((stat.wins / stat.matches) * 100).toFixed(0)}%
</Text>
</Group>
</div>
))}
</Stack>
</div>
)}
</Stack>
</Box>
);
};
export default StatsOverview;

View File

@@ -1,11 +1,12 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats } from "./server";
export const playerKeys = {
auth: ['auth'],
list: ['players', 'list'],
details: (id: string) => ['players', 'details', id],
unassociated: ['players','unassociated'],
stats: (id: string) => ['players', 'stats', id],
};
export const playerQueries = {
@@ -25,6 +26,10 @@ export const playerQueries = {
queryKey: playerKeys.unassociated,
queryFn: async () => await getUnassociatedPlayers()
}),
stats: (id: string) => ({
queryKey: playerKeys.stats(id),
queryFn: async () => await getPlayerStats({ data: id })
}),
};
export const useMe = () => {
@@ -57,4 +62,7 @@ export const usePlayers = () =>
useServerSuspenseQuery(playerQueries.list());
export const useUnassociatedPlayers = () =>
useServerSuspenseQuery(playerQueries.unassociated());
useServerSuspenseQuery(playerQueries.unassociated());
export const usePlayerStats = (id: string) =>
useServerSuspenseQuery(playerQueries.stats(id));

View File

@@ -1,6 +1,6 @@
import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { logger } from ".";
@@ -118,4 +118,11 @@ export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(pbAdmin.getUnassociatedPlayers)
);
export const getPlayerStats = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data))
);

View File

@@ -27,3 +27,18 @@ export const playerUpdateSchema = playerInputSchema.partial();
export type PlayerInput = z.infer<typeof playerInputSchema>;
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;
export interface PlayerStats {
id: string;
player_id: string;
player_name: string;
matches: number;
wins: number;
losses: number;
total_cups_made: number;
total_cups_against: number;
win_percentage: number;
avg_cups_per_match: number;
margin_of_victory: number;
margin_of_loss: number;
}

View File

@@ -3,6 +3,7 @@ import type {
PlayerInfo,
PlayerInput,
PlayerUpdateInput,
PlayerStats,
} from "@/features/players/types";
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
import PocketBase from "pocketbase";
@@ -62,5 +63,12 @@ export function createPlayersService(pb: PocketBase) {
});
return result.map(transformPlayer);
},
async getPlayerStats(playerId: string): Promise<PlayerStats[]> {
const result = await pb.collection("player_stats").getFullList<PlayerStats>({
filter: `player_id = "${playerId}"`,
});
return result;
},
};
}