dark mode default, basic tournament stats/podium

This commit is contained in:
yohlo
2025-09-22 19:33:58 -05:00
parent b93ce38d48
commit 7ff26229d9
8 changed files with 228 additions and 71 deletions

View File

@@ -19,7 +19,7 @@ interface AuthData {
export const defaultAuthData: AuthData = { export const defaultAuthData: AuthData = {
user: undefined, user: undefined,
metadata: { accentColor: "blue", colorScheme: "auto" }, metadata: { accentColor: "blue", colorScheme: "dark" },
roles: [], roles: [],
phone: "" phone: ""
}; };

View File

@@ -1,16 +1,13 @@
import { Title, AppShell, Flex } from "@mantine/core"; import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import useRouterConfig from "../hooks/use-router-config";
import BackButton from "./back-button"; import BackButton from "./back-button";
interface HeaderProps extends HeaderConfig {} interface HeaderProps extends HeaderConfig {}
const Header = ({ collapsed, title }: HeaderProps) => { const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
const { header } = useRouterConfig();
return ( return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}> <AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
{ header.withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'> <Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
</Flex> </Flex>

View File

@@ -78,8 +78,7 @@ const EmojiBar = ({
{visibleReactions.map((reaction) => ( {visibleReactions.map((reaction) => (
<Button <Button
key={reaction.emoji} key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"} variant={"light"}
color="gray"
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined} bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs" size="compact-xs"
radius="xl" radius="xl"
@@ -109,8 +108,7 @@ const EmojiBar = ({
{hasGrouped && ( {hasGrouped && (
<Button <Button
variant={userHasReactedToGrouped ? "filled" : "light"} variant={"light"}
color="gray"
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined} bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs" size="compact-xs"
radius="xl" radius="xl"

View File

@@ -1,48 +1,27 @@
import { import {
Badge,
Card, Card,
Text, Text,
Stack, Stack,
Group, Group,
Box,
ThemeIcon,
UnstyledButton, UnstyledButton,
Badge,
} from "@mantine/core"; } from "@mantine/core";
import { Tournament } from "@/features/tournaments/types"; import { TournamentInfo } from "@/features/tournaments/types";
import { useMemo } from "react";
import { import {
TrophyIcon, TrophyIcon,
CalendarIcon, CrownIcon,
MapPinIcon, MedalIcon,
UsersIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
interface TournamentCardProps { interface TournamentCardProps {
tournament: Tournament; tournament: TournamentInfo;
} }
export const TournamentCard = ({ tournament }: TournamentCardProps) => { export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const displayDate = useMemo(() => {
if (!tournament.start_time) return null;
const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}, [tournament.start_time]);
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const enrolledTeamsCount = tournament.teams?.length || 0;
return ( return (
<UnstyledButton <UnstyledButton
w="100%" w="100%"
@@ -93,31 +72,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Text fw={600} size="lg" lineClamp={2}> <Text fw={600} size="lg" lineClamp={2}>
{tournament.name} {tournament.name}
</Text> </Text>
{displayDate && ( {(tournament.first_place || tournament.second_place || tournament.third_place) && (
<Group gap="xs"> <Stack gap={6} >
<ThemeIcon {tournament.first_place && (
size="sm" <Badge
variant="light" size="md"
radius="sm" radius="md"
color="gray" variant="filled"
color="yellow"
leftSection={
<CrownIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 600,
color: 'white',
}}
> >
<CalendarIcon size={12} /> {tournament.first_place.name}
</ThemeIcon> </Badge>
<Text size="sm" c="dimmed"> )}
{displayDate} {tournament.second_place && (
</Text> <Badge
</Group> size="md"
radius="md"
color="gray"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.second_place.name}
</Badge>
)}
{tournament.third_place && (
<Badge
size="md"
radius="md"
color="orange"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.third_place.name}
</Badge>
)}
</Stack>
)} )}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm" color="gray">
<UsersIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} team
{enrolledTeamsCount !== 1 ? "s" : ""}
</Text>
</Group>
</Stack> </Stack>
</Group> </Group>
</Group> </Group>

View File

@@ -2,6 +2,22 @@ import { TeamInfo } from "@/features/teams/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { z } from "zod"; import { z } from "zod";
export interface TournamentTeamStats {
id: string;
team_id: string;
tournament_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;
win_percentage: number;
avg_cups_per_match: number;
}
export interface TournamentInfo { export interface TournamentInfo {
id: string; id: string;
name: string; name: string;
@@ -9,6 +25,9 @@ export interface TournamentInfo {
start_time?: string; start_time?: string;
end_time?: string; end_time?: string;
logo?: string; logo?: string;
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
} }
export interface Tournament { export interface Tournament {
@@ -25,6 +44,10 @@ export interface Tournament {
updated: string; updated: string;
teams?: TeamInfo[]; teams?: TeamInfo[];
matches?: Match[]; matches?: Match[];
first_place?: TeamInfo;
second_place?: TeamInfo;
third_place?: TeamInfo;
team_stats?: TournamentTeamStats[];
} }
export const tournamentInputSchema = z.object({ export const tournamentInputSchema = z.object({

View File

@@ -55,7 +55,7 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
setIsHydrated(true); setIsHydrated(true);
}, []); }, []);
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto"; const colorScheme = isHydrated ? metadata.colorScheme || "dark" : "dark";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue"; const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
return ( return (

View File

@@ -9,16 +9,24 @@ import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types"; import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
import { transformTeam } from "@/lib/pocketbase/util/transform-types"; import { transformTeam } from "@/lib/pocketbase/util/transform-types";
import { getFreeAgents } from "@/features/tournaments/server";
import { PlayerInfo } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
export function createTournamentsService(pb: PocketBase) { export function createTournamentsService(pb: PocketBase) {
return { return {
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> { async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
const result = await pb.collection("tournaments").getOne(id, { const [tournamentResult, teamStatsResult] = await Promise.all([
pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players", expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
}); }),
return transformTournament(result, isAdmin); pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${id}"`,
sort: "-wins,-total_cups_made"
})
]);
tournamentResult.team_stats = teamStatsResult;
return transformTournament(tournamentResult, isAdmin);
}, },
async getMostRecentTournament(): Promise<Tournament> { async getMostRecentTournament(): Promise<Tournament> {
const result = await pb const result = await pb
@@ -29,17 +37,35 @@ export function createTournamentsService(pb: PocketBase) {
sort: "-created", sort: "-created",
} }
); );
const teamStatsResult = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${result.id}"`,
sort: "-wins,-total_cups_made"
});
result.team_stats = teamStatsResult;
return transformTournament(result); return transformTournament(result);
}, },
async listTournaments(): Promise<TournamentInfo[]> { async listTournaments(): Promise<TournamentInfo[]> {
const result = await pb const result = await pb
.collection("tournaments") .collection("tournaments")
.getFullList({ .getFullList({
fields: "id,name,location,start_time,end_time,logo", expand: "teams,teams.players,matches",
sort: "-created", sort: "-created",
}); });
return result.map(transformTournamentInfo); const tournamentsWithStats = await Promise.all(result.map(async (tournament) => {
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${tournament.id}"`,
sort: "-wins,-total_cups_made"
});
tournament.team_stats = teamStats;
return tournament;
}));
return tournamentsWithStats.map(transformTournamentInfo);
}, },
async createTournament(data: TournamentInput): Promise<Tournament> { async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb const result = await pb

View File

@@ -57,12 +57,58 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
} }
export const transformTournamentInfo = (record: any): TournamentInfo => { export const transformTournamentInfo = (record: any): TournamentInfo => {
// Check if tournament is complete by looking at matches
const matches = record.expand?.matches || [];
// Filter out bye matches (tbd status with bye=true) when checking completion
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
let first_place: TeamInfo | undefined = undefined;
let second_place: TeamInfo | undefined = undefined;
let third_place: TeamInfo | undefined = undefined;
if (isComplete) {
const teams = record.expand?.teams || [];
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, transformTeamInfo(team)]));
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
}
if (losersFinale && losersFinale.status === 'ended') {
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
}
}
return { return {
id: record.id, id: record.id,
name: record.name, name: record.name,
location: record.location, location: record.location,
start_time: record.start_time, start_time: record.start_time,
end_time: record.end_time,
logo: record.logo, logo: record.logo,
first_place,
second_place,
third_place,
}; };
} }
@@ -150,6 +196,59 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
) )
?.map((match: any) => transformMatch(match, isAdmin)) ?? []; ?.map((match: any) => transformMatch(match, isAdmin)) ?? [];
const team_stats = record.team_stats?.map((stat: any) => ({
id: stat.id,
team_id: stat.team_id,
tournament_id: stat.tournament_id,
team_name: stat.team_name,
matches: stat.matches,
wins: stat.wins,
losses: stat.losses,
total_cups_made: stat.total_cups_made,
total_cups_against: stat.total_cups_against,
margin_of_victory: stat.margin_of_victory,
margin_of_loss: stat.margin_of_loss,
win_percentage: (stat.wins / stat.matches) * 100,
avg_cups_per_match: stat.total_cups_made / stat.matches,
})) ?? [];
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
let first_place: TeamInfo | undefined = undefined;
let second_place: TeamInfo | undefined = undefined;
let third_place: TeamInfo | undefined = undefined;
if (isComplete) {
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, team]));
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
}
if (losersFinale && losersFinale.status === 'ended') {
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
}
}
return { return {
id: record.id, id: record.id,
name: record.name, name: record.name,
@@ -163,7 +262,11 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
created: record.created, created: record.created,
updated: record.updated, updated: record.updated,
teams, teams,
matches matches,
first_place,
second_place,
third_place,
team_stats,
}; };
} }