From 9a105b30c6b1e23de52305210d6ce446ac9d9a4a Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 14 Sep 2025 23:10:05 -0500 Subject: [PATCH] stats reorg, upcoming refinement --- package.json | 1 + .../1757800000_created_team_stats.js | 211 +++++++++++++ src/app/routes/__root.tsx | 7 + src/app/routes/_authed/index.tsx | 3 +- .../players/components/player-stats-table.tsx | 29 +- .../players/components/profile/index.tsx | 22 +- .../players/components/stats-overview.tsx | 270 ----------------- .../teams/components/team-profile/index.tsx | 6 +- src/features/teams/queries.ts | 16 +- src/features/teams/server.ts | 9 +- src/features/teams/types.ts | 13 + .../components/upcoming-tournament/header.tsx | 72 +++++ .../index.tsx} | 76 +---- .../upcoming-tournament/team-list-button.tsx | 31 ++ src/lib/pocketbase/services/teams.ts | 17 +- src/shared/components/stats-overview.tsx | 281 ++++++++++++++++++ src/shared/types/stats.ts | 10 + src/utils/supertokens.ts | 2 - 18 files changed, 703 insertions(+), 373 deletions(-) create mode 100644 pb_migrations/1757800000_created_team_stats.js delete mode 100644 src/features/players/components/stats-overview.tsx create mode 100644 src/features/tournaments/components/upcoming-tournament/header.tsx rename src/features/tournaments/components/{upcoming-tournament.tsx => upcoming-tournament/index.tsx} (51%) create mode 100644 src/features/tournaments/components/upcoming-tournament/team-list-button.tsx create mode 100644 src/shared/components/stats-overview.tsx create mode 100644 src/shared/types/stats.ts diff --git a/package.json b/package.json index d617fa3..ff5a532 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pb_migrations/1757800000_created_team_stats.js b/pb_migrations/1757800000_created_team_stats.js new file mode 100644 index 0000000..98d1070 --- /dev/null +++ b/pb_migrations/1757800000_created_team_stats.js @@ -0,0 +1,211 @@ +/// +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); +}) \ No newline at end of file diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 0459715..83ff2d1 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -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 ( now) { + if (!tournament.matches || tournament.matches.length === 0) { return ; } diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 0272942..fb211be 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -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) => { * Confidence penalty applied for players - with <20 matches + with <15 matches ** Not an official rating diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index d0af634..2284843 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -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: , + content: , }, { label: "Matches", diff --git a/src/features/players/components/stats-overview.tsx b/src/features/players/components/stats-overview.tsx deleted file mode 100644 index f320c34..0000000 --- a/src/features/players/components/stats-overview.tsx +++ /dev/null @@ -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 ( - - - - - {label} - - - {value !== null ? `${value}${suffix}` : "—"} - - - {Icon && ( - - - - )} - - - ); - } - - return ( - - - - - {label} - - {Icon && ( - - - - )} - - - {value !== null ? `${value}${suffix}` : "—"} - - - - ); -}; - -const StatsOverview = ({ playerId }: StatsOverviewProps) => { - const { data: statsData } = usePlayerStats(playerId); - - if (!statsData || statsData.length === 0) { - return ( - -
- - No stats available yet - -
-
- ); - } - - 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 ( - - - - - - - Match Statistics - - - - - - - - - - - - - - - Metrics - - - - - - - - - - - - - - - 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null} - Icon={ArrowUpIcon} - /> - - - 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null} - Icon={ArrowDownIcon} - /> - - - - - {/* Team Performance */} - {statsData.length > 1 && ( - <> - - - Team Performance - - {statsData.map((stat) => { - const teamWinRate = (stat.wins / stat.matches) * 100; - return ( - - - - - {stat.player_name.split(' ').map(n => n[0]).join('')} - - - {stat.player_name} - - - {stat.matches} matches - - - - {stat.wins}W - - - {stat.losses}L - - - - - - - {teamWinRate.toFixed(0)}% - - - - - - ); - })} - - - - )} - - - ); -}; - -export default StatsOverview; \ No newline at end of file diff --git a/src/features/teams/components/team-profile/index.tsx b/src/features/teams/components/team-profile/index.tsx index 5b5ab7a..1a77843 100644 --- a/src/features/teams/components/team-profile/index.tsx +++ b/src/features/teams/components/team-profile/index.tsx @@ -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 Team not found; const tabs = [ { label: "Overview", - content: Stats/Badges will go here, + content: , }, { label: "Matches", diff --git a/src/features/teams/queries.ts b/src/features/teams/queries.ts index 970fb04..9e369b5 100644 --- a/src/features/teams/queries.ts +++ b/src/features/teams/queries.ts @@ -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 + }); diff --git a/src/features/teams/server.ts b/src/features/teams/server.ts index 39f615e..e548161 100644 --- a/src/features/teams/server.ts +++ b/src/features/teams/server.ts @@ -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)) + ); diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts index 8be3ba2..c77639b 100644 --- a/src/features/teams/types.ts +++ b/src/features/teams/types.ts @@ -96,3 +96,16 @@ export const teamUpdateSchema = z export type TeamInput = z.infer; export type TeamUpdateInput = z.infer; + +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; +} diff --git a/src/features/tournaments/components/upcoming-tournament/header.tsx b/src/features/tournaments/components/upcoming-tournament/header.tsx new file mode 100644 index 0000000..a5f17de --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/header.tsx @@ -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 ( + + + + + + + {tournament.location && ( + + + + + + {tournament.location} + + + )} + + + + + + + {tournamentStart.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + })}{" "} + at{" "} + {tournamentStart.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + + + + + + + + {teamCount} teams enrolled + + + + + + ) +} + +export default Header; \ No newline at end of file diff --git a/src/features/tournaments/components/upcoming-tournament.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx similarity index 51% rename from src/features/tournaments/components/upcoming-tournament.tsx rename to src/features/tournaments/components/upcoming-tournament/index.tsx index 658bb23..6e8d3c4 100644 --- a/src/features/tournaments/components/upcoming-tournament.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -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 ( - - - - - - - {tournament.location && ( - - - - - - {tournament.location} - - - )} - - - - - - - {tournamentStart.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })}{" "} - at{" "} - {tournamentStart.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - - - - - - - - - {enrolledTeamsCount} teams enrolled - - - - - + +
{tournament.desc && {tournament.desc}} @@ -149,11 +93,7 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ /> )} {}} /> - {}} - /> + ); diff --git a/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx new file mode 100644 index 0000000..f2da9fd --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx @@ -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 = ({ teams }) => { + const count = useMemo(() => teams.length, [teams]); + const { open, isOpen, toggle } = useSheet(); + return ( + <> + + + + + + + ) +} + +export default TeamListButton; \ No newline at end of file diff --git a/src/lib/pocketbase/services/teams.ts b/src/lib/pocketbase/services/teams.ts index eeea79b..6747d97 100644 --- a/src/lib/pocketbase/services/teams.ts +++ b/src/lib/pocketbase/services/teams.ts @@ -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 { 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 { + 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; + } + }, }; } diff --git a/src/shared/components/stats-overview.tsx b/src/shared/components/stats-overview.tsx new file mode 100644 index 0000000..fee533c --- /dev/null +++ b/src/shared/components/stats-overview.tsx @@ -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 ( + + + + + {label} + + {isLoading ? ( + + ) : ( + + {value !== null ? `${value}${suffix}` : "—"} + + )} + + {Icon && ( + + + + )} + + + ); + } + + return ( + + + + + {label} + + {Icon && ( + + + + )} + + {isLoading ? ( + + ) : ( + + {value !== null ? `${value}${suffix}` : "—"} + + )} + + + ); +}; + +const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => { + // Show skeleton loading state + if (isLoading || (!statsData && isLoading)) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + // Show no data state only when we know for sure there's no data + if (!statsData && !isLoading) { + return ( + + + No stats available yet + + + ); + } + + 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 ( + + + + + Match Statistics + + + + + + + + + + + + + + + + + Metrics + + + + + + + + + + + + + + + + 0 + ? parseFloat(avgMarginOfVictory.toFixed(1)) + : null + } + Icon={ArrowUpIcon} + /> + + + 0 + ? parseFloat(avgMarginOfLoss.toFixed(1)) + : null + } + Icon={ArrowDownIcon} + /> + + + + + + ); +}; + +export default StatsOverview; diff --git a/src/shared/types/stats.ts b/src/shared/types/stats.ts new file mode 100644 index 0000000..1478ace --- /dev/null +++ b/src/shared/types/stats.ts @@ -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; +} \ No newline at end of file diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 5842f66..8cca6b4 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -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) {