player stats in profile
This commit is contained in:
@@ -25,10 +25,10 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
justify="space-around"
|
justify="space-around"
|
||||||
p={24}
|
p={24}
|
||||||
>
|
>
|
||||||
{round
|
{round.map((match) =>
|
||||||
.filter((match) => !match.bye)
|
match.bye ? (
|
||||||
.map((match) => {
|
<div key={match.lid}></div>
|
||||||
return (
|
) : (
|
||||||
<div key={match.lid}>
|
<div key={match.lid}>
|
||||||
<MatchCard
|
<MatchCard
|
||||||
match={match}
|
match={match}
|
||||||
@@ -36,8 +36,8 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Player } from "@/features/players/types";
|
|||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer } from "../../queries";
|
import { usePlayer } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
|
import StatsOverview from "../stats-overview";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,7 +15,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
content: <StatsOverview playerId={id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
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 { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats } from "./server";
|
||||||
|
|
||||||
export const playerKeys = {
|
export const playerKeys = {
|
||||||
auth: ['auth'],
|
auth: ['auth'],
|
||||||
list: ['players', 'list'],
|
list: ['players', 'list'],
|
||||||
details: (id: string) => ['players', 'details', id],
|
details: (id: string) => ['players', 'details', id],
|
||||||
unassociated: ['players','unassociated'],
|
unassociated: ['players','unassociated'],
|
||||||
|
stats: (id: string) => ['players', 'stats', id],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerQueries = {
|
export const playerQueries = {
|
||||||
@@ -25,6 +26,10 @@ export const playerQueries = {
|
|||||||
queryKey: playerKeys.unassociated,
|
queryKey: playerKeys.unassociated,
|
||||||
queryFn: async () => await getUnassociatedPlayers()
|
queryFn: async () => await getUnassociatedPlayers()
|
||||||
}),
|
}),
|
||||||
|
stats: (id: string) => ({
|
||||||
|
queryKey: playerKeys.stats(id),
|
||||||
|
queryFn: async () => await getPlayerStats({ data: id })
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMe = () => {
|
export const useMe = () => {
|
||||||
@@ -58,3 +63,6 @@ export const usePlayers = () =>
|
|||||||
|
|
||||||
export const useUnassociatedPlayers = () =>
|
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 { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
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 { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
@@ -119,3 +119,10 @@ export const getUnassociatedPlayers = createServerFn()
|
|||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(pbAdmin.getUnassociatedPlayers)
|
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 PlayerInput = z.infer<typeof playerInputSchema>;
|
||||||
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;
|
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,
|
PlayerInfo,
|
||||||
PlayerInput,
|
PlayerInput,
|
||||||
PlayerUpdateInput,
|
PlayerUpdateInput,
|
||||||
|
PlayerStats,
|
||||||
} from "@/features/players/types";
|
} from "@/features/players/types";
|
||||||
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
|
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
@@ -62,5 +63,12 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return result.map(transformPlayer);
|
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