player stats in profile
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
187
src/features/players/components/stats-overview.tsx
Normal file
187
src/features/players/components/stats-overview.tsx
Normal 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;
|
||||
@@ -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));
|
||||
@@ -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))
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user