stats reorg, upcoming refinement
This commit is contained in:
@@ -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",
|
||||
|
||||
211
pb_migrations/1757800000_created_team_stats.js
Normal file
211
pb_migrations/1757800000_created_team_stats.js
Normal 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);
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <20 matches
|
||||
with <15 matches
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
** Not an official rating
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
281
src/shared/components/stats-overview.tsx
Normal file
281
src/shared/components/stats-overview.tsx
Normal 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
10
src/shared/types/stats.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user