stats reorg, upcoming refinement
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
|
"react-scan": "^0.4.3",
|
||||||
"react-use-draggable-scroll": "^0.4.7",
|
"react-use-draggable-scroll": "^0.4.7",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"redaxios": "^0.5.1",
|
"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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
|
import { scan } from "react-scan";
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
@@ -105,6 +106,12 @@ function RootComponent() {
|
|||||||
|
|
||||||
// todo: analytics -> process.env data-website-id
|
// todo: analytics -> process.env data-website-id
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
scan({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
{...mantineHtmlProps}
|
{...mantineHtmlProps}
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ export const Route = createFileRoute("/_authed/")({
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data: tournament } = useCurrentTournament();
|
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} />;
|
return <UpcomingTournament tournament={tournament} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,37 +43,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate MMR (Match Making Rating) based on multiple factors
|
|
||||||
const calculateMMR = (stat: PlayerStats): number => {
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
if (stat.matches === 0) return 0;
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
// Base score from win percentage (0-100)
|
|
||||||
const winScore = stat.win_percentage;
|
const winScore = stat.win_percentage;
|
||||||
|
|
||||||
// Match confidence factor (more matches = more reliable)
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
||||||
// Cap at 20 matches for full confidence
|
|
||||||
const matchConfidence = Math.min(stat.matches / 20, 1);
|
|
||||||
|
|
||||||
// Performance metrics
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
||||||
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
|
|
||||||
const marginScore = stat.margin_of_victory
|
const marginScore = stat.margin_of_victory
|
||||||
? Math.min(stat.margin_of_victory * 20, 50)
|
? 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);
|
||||||
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
|
|
||||||
|
|
||||||
// Weighted calculation
|
|
||||||
const baseMMR =
|
const baseMMR =
|
||||||
winScore * 0.5 + // Win % is 50% of score
|
winScore * 0.5 +
|
||||||
avgCupsScore * 0.25 + // Avg cups is 25% of score
|
avgCupsScore * 0.25 +
|
||||||
marginScore * 0.15 + // Win margin is 15% of score
|
marginScore * 0.15 +
|
||||||
volumeBonus * 0.1; // Volume bonus is 10% of score
|
volumeBonus * 0.1;
|
||||||
|
|
||||||
// Apply confidence factor (players with few matches get penalized)
|
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
|
||||||
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
|
return Math.round(finalMMR * 10) / 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
const handleSort = (key: SortKey) => {
|
||||||
@@ -101,7 +93,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
let aValue: number | string;
|
let aValue: number | string;
|
||||||
let bValue: number | string;
|
let bValue: number | string;
|
||||||
|
|
||||||
// Special handling for MMR
|
|
||||||
if (sortConfig.key === "mmr") {
|
if (sortConfig.key === "mmr") {
|
||||||
aValue = calculateMMR(a);
|
aValue = calculateMMR(a);
|
||||||
bValue = calculateMMR(b);
|
bValue = calculateMMR(b);
|
||||||
@@ -315,7 +306,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
* Confidence penalty applied for players
|
* Confidence penalty applied for players
|
||||||
with <20 matches
|
with <15 matches
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
** Not an official rating
|
** Not an official rating
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player, PlayerStats } from "@/features/players/types";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
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 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 MatchList from "@/features/matches/components/match-list";
|
||||||
|
import { BaseStats } from "@/shared/types/stats";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,11 +15,24 @@ interface ProfileProps {
|
|||||||
const Profile = ({ id }: ProfileProps) => {
|
const Profile = ({ id }: ProfileProps) => {
|
||||||
const { data: player } = usePlayer(id);
|
const { data: player } = usePlayer(id);
|
||||||
const { data: matches } = usePlayerMatches(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 = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <StatsOverview playerId={id} />,
|
content: <StatsOverview statsData={aggregatedStats} isLoading={statsLoading} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
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 Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
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 {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,12 +11,13 @@ interface ProfileProps {
|
|||||||
|
|
||||||
const TeamProfile = ({ id }: ProfileProps) => {
|
const TeamProfile = ({ id }: ProfileProps) => {
|
||||||
const { data: team } = useTeam(id);
|
const { data: team } = useTeam(id);
|
||||||
|
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
|
||||||
if (!team) return <Text p="md">Team not found</Text>;
|
if (!team) return <Text p="md">Team not found</Text>;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
content: <StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getTeam } from "./server";
|
import { getTeam, getTeamStats } from "./server";
|
||||||
|
|
||||||
export const teamKeys = {
|
export const teamKeys = {
|
||||||
details: (id: string) => ['teams', 'details', id] as const,
|
details: (id: string) => ['teams', 'details', id] as const,
|
||||||
|
stats: (id: string) => ['teams', 'stats', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const teamQueries = {
|
export const teamQueries = {
|
||||||
@@ -10,7 +11,18 @@ export const teamQueries = {
|
|||||||
queryKey: teamKeys.details(id),
|
queryKey: teamKeys.details(id),
|
||||||
queryFn: () => getTeam({ data: id }),
|
queryFn: () => getTeam({ data: id }),
|
||||||
}),
|
}),
|
||||||
|
stats: (id: string) => ({
|
||||||
|
queryKey: teamKeys.stats(id),
|
||||||
|
queryFn: () => getTeamStats({ data: id }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTeam = (id: string) =>
|
export const useTeam = (id: string) =>
|
||||||
useServerSuspenseQuery(teamQueries.details(id));
|
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
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { id, updates }, context }) =>
|
.handler(async ({ data: { id, updates }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
@@ -71,3 +71,10 @@ export const updateTeam = createServerFn()
|
|||||||
return pbAdmin.updateTeam(id, updates);
|
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 TeamInput = z.infer<typeof teamInputSchema>;
|
||||||
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { Tournament } from "../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import EnrollTeam from "./enroll-team";
|
import EnrollTeam from "../enroll-team";
|
||||||
import EnrollFreeAgent from "./enroll-free-agent";
|
import EnrollFreeAgent from "../enroll-free-agent";
|
||||||
|
import TeamListButton from "./team-list-button";
|
||||||
|
import Header from "./header";
|
||||||
|
|
||||||
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
@@ -44,70 +46,12 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
const enrollmentDeadline = tournament.enroll_time
|
const enrollmentDeadline = tournament.enroll_time
|
||||||
? new Date(tournament.enroll_time)
|
? new Date(tournament.enroll_time)
|
||||||
: new Date(tournament.start_time);
|
: new Date(tournament.start_time);
|
||||||
const tournamentStart = new Date(tournament.start_time);
|
|
||||||
const isEnrollmentOpen = enrollmentDeadline > new Date();
|
const isEnrollmentOpen = enrollmentDeadline > new Date();
|
||||||
|
|
||||||
const enrolledTeamsCount = tournament.teams?.length || 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-around" align="flex-start" w='100%'>
|
|
||||||
<Group align="center" gap="lg">
|
<Header tournament={tournament} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
{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 Rules" Icon={ListIcon} onClick={() => {}} />
|
||||||
<ListButton
|
<TeamListButton teams={tournament.teams || []} />
|
||||||
label={`View Teams (${enrolledTeamsCount})`}
|
|
||||||
Icon={UsersIcon}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</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 { logger } from "@/lib/logger";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
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) {
|
export function createTeamsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
@@ -64,7 +64,7 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
|
|
||||||
async updateTeam(id: string, data: TeamUpdateInput): Promise<Team> {
|
async updateTeam(id: string, data: TeamUpdateInput): Promise<Team> {
|
||||||
logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
|
logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
|
const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
|
||||||
if (!existingTeam) {
|
if (!existingTeam) {
|
||||||
@@ -72,7 +72,7 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pb.collection("teams").update(id, data);
|
const result = await pb.collection("teams").update(id, data);
|
||||||
|
|
||||||
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
||||||
expand: "players, tournaments"
|
expand: "players, tournaments"
|
||||||
}));
|
}));
|
||||||
@@ -81,5 +81,16 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
throw error;
|
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");
|
logger.info("Session needs refresh");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// attempt refresh on backend
|
|
||||||
if (response) {
|
if (response) {
|
||||||
const refreshedSession = await refreshSession(request, response);
|
const refreshedSession = await refreshSession(request, response);
|
||||||
if (refreshedSession) {
|
if (refreshedSession) {
|
||||||
@@ -33,7 +32,6 @@ export const verifySuperTokensSession = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session?.needsRefresh) {
|
if (session?.needsRefresh) {
|
||||||
// tryRefresh on frontend
|
|
||||||
return { context: { session: { tryRefresh: true } } };
|
return { context: { session: { tryRefresh: true } } };
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user