predictions
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_340646327",
|
||||
"hidden": false,
|
||||
"id": "relation3177167065",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "tournament",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2551806565",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2153001328",
|
||||
"maxSize": 0,
|
||||
"name": "picks",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1784007613",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "predictions",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1784007613");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -38,10 +38,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
|
||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||
@@ -193,6 +196,12 @@ const AuthedAdminTournamentsIndexRoute =
|
||||
path: '/tournaments/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsRoute =
|
||||
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||
id: '/tournaments/$id/predictions',
|
||||
path: '/tournaments/$id/predictions',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdGroupsRoute =
|
||||
AuthedTournamentsIdGroupsRouteImport.update({
|
||||
id: '/tournaments/$id/groups',
|
||||
@@ -217,6 +226,18 @@ const ApiFilesCollectionRecordIdFileRoute =
|
||||
path: '/api/files/$collection/$recordId/$file',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsMakeRoute =
|
||||
AuthedTournamentsIdPredictionsMakeRouteImport.update({
|
||||
id: '/tournaments/$id/predictions_/make',
|
||||
path: '/tournaments/$id/predictions/make',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsPlayerIdRoute =
|
||||
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
|
||||
id: '/tournaments/$id/predictions_/$playerId',
|
||||
path: '/tournaments/$id/predictions/$playerId',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsRunIdRoute =
|
||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||
id: '/tournaments/run/$id',
|
||||
@@ -266,10 +287,13 @@ export interface FileRoutesByFullPath {
|
||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -302,10 +326,13 @@ export interface FileRoutesByTo {
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -341,10 +368,13 @@ export interface FileRoutesById {
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -380,10 +410,13 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/tournaments/'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
| '/tournaments/$id/predictions/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -416,10 +449,13 @@ export interface FileRouteTypes {
|
||||
| '/tournaments'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
| '/tournaments/$id/predictions/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id'
|
||||
id:
|
||||
@@ -454,10 +490,13 @@ export interface FileRouteTypes {
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/tournaments/$id/groups'
|
||||
| '/_authed/tournaments/$id/predictions'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
| '/_authed/admin/tournaments/run/$id'
|
||||
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||
| '/_authed/tournaments/$id/predictions_/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/_authed/admin/tournaments/$id/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -686,6 +725,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions': {
|
||||
id: '/_authed/tournaments/$id/predictions'
|
||||
path: '/tournaments/$id/predictions'
|
||||
fullPath: '/tournaments/$id/predictions'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/groups': {
|
||||
id: '/_authed/tournaments/$id/groups'
|
||||
path: '/tournaments/$id/groups'
|
||||
@@ -714,6 +760,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions_/make': {
|
||||
id: '/_authed/tournaments/$id/predictions_/make'
|
||||
path: '/tournaments/$id/predictions/make'
|
||||
fullPath: '/tournaments/$id/predictions/make'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions_/$playerId': {
|
||||
id: '/_authed/tournaments/$id/predictions_/$playerId'
|
||||
path: '/tournaments/$id/predictions/$playerId'
|
||||
fullPath: '/tournaments/$id/predictions/$playerId'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/run/$id': {
|
||||
id: '/_authed/admin/tournaments/run/$id'
|
||||
path: '/tournaments/run/$id'
|
||||
@@ -779,6 +839,9 @@ interface AuthedRouteChildren {
|
||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
}
|
||||
|
||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
@@ -793,6 +856,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute:
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute,
|
||||
AuthedTournamentsIdPredictionsMakeRoute:
|
||||
AuthedTournamentsIdPredictionsMakeRoute,
|
||||
}
|
||||
|
||||
const AuthedRouteWithChildren =
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
useTournament,
|
||||
} from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
||||
import { Container } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
|
||||
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function BracketPending() {
|
||||
const columns = [4, 2, 1];
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<Box
|
||||
p={0}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
minHeight: "70dvh",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||
<Flex gap="xl" px={16} align="stretch">
|
||||
{columns.map((count, columnIndex) => (
|
||||
<Stack
|
||||
key={`bracket-pending-round-${columnIndex}`}
|
||||
gap="xl"
|
||||
justify="space-around"
|
||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||
<Skeleton
|
||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||
height={84}
|
||||
width={220}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
const bracket: BracketData = useMemo(() => {
|
||||
if (!tournament.matches || tournament.matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
}
|
||||
|
||||
const winnersMap = new Map<number, Match[]>();
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
tournament.matches
|
||||
.filter((match) => match.round !== -1)
|
||||
.sort((a, b) => a.lid - b.lid)
|
||||
.forEach((match) => {
|
||||
if (!match.is_losers_bracket) {
|
||||
if (!winnersMap.has(match.round)) {
|
||||
winnersMap.set(match.round, []);
|
||||
}
|
||||
winnersMap.get(match.round)!.push(match);
|
||||
} else {
|
||||
if (!losersMap.has(match.round)) {
|
||||
losersMap.set(match.round, []);
|
||||
}
|
||||
losersMap.get(match.round)!.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
const winners = Array.from(winnersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
|
||||
const losers = Array.from(losersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
return { winners, losers };
|
||||
}, [tournament.matches]);
|
||||
const bracket: BracketData = useMemo(
|
||||
() => groupMatchesIntoBracket(tournament.matches),
|
||||
[tournament.matches]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Container } from "@mantine/core";
|
||||
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
||||
|
||||
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: "Predictions",
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<PredictionLeaderboard tournament={tournament} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
|
||||
import { computePredictionScore } from "@/features/predictions/utils";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authed/tournaments/$id/predictions_/$playerId"
|
||||
)({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
|
||||
const prediction = await ensureServerQueryData(
|
||||
queryClient,
|
||||
predictionQueries.player(params.id, params.playerId)
|
||||
);
|
||||
if (!prediction) {
|
||||
throw redirect({
|
||||
to: "/tournaments/$id/predictions",
|
||||
params: { id: params.id },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tournament,
|
||||
prediction,
|
||||
};
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.prediction.player.first_name}'s Bracket`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id, playerId } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
const { data: prediction } = usePlayerPrediction(id, playerId);
|
||||
|
||||
const matches = tournament.matches || [];
|
||||
const picks = prediction?.picks ?? {};
|
||||
|
||||
const score = useMemo(
|
||||
() => computePredictionScore(matches, picks),
|
||||
[matches, picks]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<Box pos="relative">
|
||||
<PredictionBracket
|
||||
matches={matches}
|
||||
picks={picks}
|
||||
mode="view"
|
||||
perMatch={score.perMatch}
|
||||
/>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
p="md"
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="lg"
|
||||
p="sm"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<PlayerAvatar
|
||||
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
|
||||
size={32}
|
||||
disableFullscreen
|
||||
/>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{prediction?.player.first_name} {prediction?.player.last_name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
PTS
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{score.points}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
PICKS
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{score.correct}/{score.total}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Container } from "@mantine/core";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authed/tournaments/$id/predictions_/make"
|
||||
)({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
|
||||
const myPrediction = await ensureServerQueryData(
|
||||
queryClient,
|
||||
predictionQueries.mine(params.id)
|
||||
);
|
||||
if (!myPrediction.eligible || myPrediction.locked) {
|
||||
throw redirect({
|
||||
to: "/tournaments/$id/predictions",
|
||||
params: { id: params.id },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.tournament.name}`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
const { data: myPrediction } = useMyPrediction(id);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<PredictionEditor
|
||||
tournament={tournament}
|
||||
initialPicks={myPrediction.prediction?.picks ?? {}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { Icon, IconProps } from "@phosphor-icons/react";
|
||||
|
||||
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
|
||||
(
|
||||
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
|
||||
ref
|
||||
) => {
|
||||
const strokeWidth = weight === "bold" ? 20 : 16;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
style={{
|
||||
transform: mirrored ? "scale(-1, 1)" : undefined,
|
||||
...style,
|
||||
}}
|
||||
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
||||
{...rest}
|
||||
>
|
||||
<circle
|
||||
cx="128"
|
||||
cy="112"
|
||||
r="66"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={weight === "duotone" ? color : "none"}
|
||||
fillOpacity={weight === "duotone" ? 0.18 : undefined}
|
||||
/>
|
||||
<path
|
||||
d="M94 96 a 44 44 0 0 1 30 -26"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth * 0.7}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M96 194 h64 l16 26 h-96 z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
) as Icon;
|
||||
|
||||
export default WizardOrbIcon;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
export function BracketPending() {
|
||||
const columns = [4, 2, 1];
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<Box
|
||||
p={0}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
minHeight: "70dvh",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||
<Flex gap="xl" px={16} align="stretch">
|
||||
{columns.map((count, columnIndex) => (
|
||||
<Stack
|
||||
key={`bracket-pending-round-${columnIndex}`}
|
||||
gap="xl"
|
||||
justify="space-around"
|
||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||
<Skeleton
|
||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||
height={84}
|
||||
width={220}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,11 @@ interface BracketViewProps {
|
||||
num_groups: number;
|
||||
advance_per_group: number;
|
||||
};
|
||||
renderMatch?: (match: Match) => React.ReactNode;
|
||||
bottomOffset?: number;
|
||||
}
|
||||
|
||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
|
||||
const height = useAppShellHeight();
|
||||
const orders = useMemo(() => {
|
||||
const map: Record<number, number> = {};
|
||||
@@ -37,16 +39,17 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Winners Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} />
|
||||
</div>
|
||||
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} />
|
||||
</div>
|
||||
)}
|
||||
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
||||
</ScrollArea>
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface BracketProps {
|
||||
num_groups: number;
|
||||
advance_per_group: number;
|
||||
};
|
||||
renderMatch?: (match: Match) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const Bracket: React.FC<BracketProps> = ({
|
||||
@@ -18,6 +19,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
orders,
|
||||
showControls,
|
||||
groupConfig,
|
||||
renderMatch,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
@@ -133,12 +135,16 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
<div key={match.lid}></div>
|
||||
) : (
|
||||
<div key={match.lid}>
|
||||
<MatchCard
|
||||
match={match}
|
||||
orders={orders}
|
||||
showControls={showControls}
|
||||
groupConfig={groupConfig}
|
||||
/>
|
||||
{renderMatch ? (
|
||||
renderMatch(match)
|
||||
) : (
|
||||
<MatchCard
|
||||
match={match}
|
||||
orders={orders}
|
||||
showControls={showControls}
|
||||
groupConfig={groupConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { TeamInfo } from "@/features/teams/types";
|
||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||
import classes from "./match-slot.module.css";
|
||||
|
||||
export type MatchSlotState = "winner" | "correct" | "incorrect";
|
||||
|
||||
interface MatchSlotProps {
|
||||
from?: number;
|
||||
from_loser?: boolean;
|
||||
@@ -14,6 +16,7 @@ interface MatchSlotProps {
|
||||
cups?: number;
|
||||
isWinner?: boolean;
|
||||
groupLabel?: string;
|
||||
state?: MatchSlotState;
|
||||
}
|
||||
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
@@ -23,7 +26,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
seed,
|
||||
cups,
|
||||
isWinner,
|
||||
groupLabel
|
||||
groupLabel,
|
||||
state,
|
||||
}) => {
|
||||
const teamId = team?.id;
|
||||
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
||||
@@ -37,11 +41,19 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
const slotState: MatchSlotState | undefined =
|
||||
state ?? (isWinner ? "winner" : undefined);
|
||||
const highlighted = slotState === "winner" || slotState === "correct";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="stretch"
|
||||
style={{
|
||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||
backgroundColor: highlighted
|
||||
? 'var(--mantine-color-green-light)'
|
||||
: slotState === "incorrect"
|
||||
? 'var(--mantine-color-red-light)'
|
||||
: 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
transition: 'background-color 200ms ease',
|
||||
}}
|
||||
@@ -60,11 +72,12 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
<Text
|
||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||
truncate
|
||||
c={slotState === "incorrect" ? "dimmed" : undefined}
|
||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
{isWinner && (
|
||||
{highlighted && (
|
||||
<CrownIcon
|
||||
size={14}
|
||||
weight="fill"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { BracketData } from "../types";
|
||||
|
||||
export const groupMatchesIntoBracket = (matches?: Match[]): BracketData => {
|
||||
if (!matches || matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
}
|
||||
|
||||
const winnersMap = new Map<number, Match[]>();
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
matches
|
||||
.filter((match) => match.round !== -1)
|
||||
.sort((a, b) => a.lid - b.lid)
|
||||
.forEach((match) => {
|
||||
if (!match.is_losers_bracket) {
|
||||
if (!winnersMap.has(match.round)) {
|
||||
winnersMap.set(match.round, []);
|
||||
}
|
||||
winnersMap.get(match.round)!.push(match);
|
||||
} else {
|
||||
if (!losersMap.has(match.round)) {
|
||||
losersMap.set(match.round, []);
|
||||
}
|
||||
losersMap.get(match.round)!.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
const winners = Array.from(winnersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
|
||||
const losers = Array.from(losersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
return { winners, losers };
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||
|
||||
interface MatchupSheetProps {
|
||||
home?: TeamInfo;
|
||||
away?: TeamInfo;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const TeamRow = ({ team }: { team: TeamInfo }) => (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<TeamAvatar team={team} size={40} radius="sm" />
|
||||
<Text size="sm" fw={600} lineClamp={2}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={4} align="flex-end">
|
||||
{team.players?.map((player) => {
|
||||
const name = `${player.first_name} ${player.last_name}`;
|
||||
return (
|
||||
<Group key={player.id} gap={6} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<PlayerAvatar name={name} size={20} disableFullscreen />
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export const MatchupSheet: React.FC<MatchupSheetProps> = ({
|
||||
home,
|
||||
away,
|
||||
isOpen,
|
||||
}) => {
|
||||
if (!home && !away) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Pick the earlier matches first — these teams aren't decided yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" withBorder radius="md">
|
||||
<Stack gap="sm">
|
||||
{home ? (
|
||||
<TeamRow team={home} />
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Home team TBD — pick the earlier matches first
|
||||
</Text>
|
||||
)}
|
||||
<Divider label="vs" labelPosition="center" />
|
||||
{away ? (
|
||||
<TeamRow team={away} />
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Away team TBD — pick the earlier matches first
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{home && away && (
|
||||
<TeamHeadToHeadSheet team1={home} team2={away} isOpen={isOpen} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from "react";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { PicksMap } from "../types";
|
||||
import { PickResult, resolvePredictedBracket } from "../utils";
|
||||
import { PredictionMatchCard } from "./prediction-match-card";
|
||||
|
||||
interface PredictionBracketProps {
|
||||
matches: Match[];
|
||||
picks: PicksMap;
|
||||
mode: "edit" | "view";
|
||||
perMatch?: Map<number, PickResult>;
|
||||
activeLid?: number;
|
||||
onActivate?: (lid: number) => void;
|
||||
}
|
||||
|
||||
export const PredictionBracket: React.FC<PredictionBracketProps> = ({
|
||||
matches,
|
||||
picks,
|
||||
mode,
|
||||
perMatch,
|
||||
activeLid,
|
||||
onActivate,
|
||||
}) => {
|
||||
const bracket = useMemo(() => groupMatchesIntoBracket(matches), [matches]);
|
||||
|
||||
const resolved = useMemo(
|
||||
() => resolvePredictedBracket(matches, picks),
|
||||
[matches, picks]
|
||||
);
|
||||
|
||||
const orders = useMemo(() => {
|
||||
const map: Record<number, number> = {};
|
||||
bracket.winners.flat().forEach((match) => (map[match.lid] = match.order));
|
||||
bracket.losers.flat().forEach((match) => (map[match.lid] = match.order));
|
||||
return map;
|
||||
}, [bracket]);
|
||||
|
||||
return (
|
||||
<BracketView
|
||||
bracket={bracket}
|
||||
bottomOffset={110}
|
||||
renderMatch={(match) => (
|
||||
<PredictionMatchCard
|
||||
match={match}
|
||||
resolved={resolved.get(match.lid)}
|
||||
orders={orders}
|
||||
mode={mode}
|
||||
result={perMatch?.get(match.lid)}
|
||||
active={activeLid === match.lid}
|
||||
onActivate={
|
||||
onActivate &&
|
||||
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||
? () => onActivate(match.lid)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { PicksMap } from "../types";
|
||||
import { useSubmitPrediction } from "../queries";
|
||||
import {
|
||||
getMatchLabel,
|
||||
getPickableMatches,
|
||||
resolvePredictedBracket,
|
||||
setPick,
|
||||
} from "../utils";
|
||||
import { PredictionBracket } from "./prediction-bracket";
|
||||
import { MatchupSheet } from "./matchup-sheet";
|
||||
import { WinnerSelector } from "./winner-selector";
|
||||
|
||||
interface PredictionEditorProps {
|
||||
tournament: Tournament;
|
||||
initialPicks: PicksMap;
|
||||
}
|
||||
|
||||
export const PredictionEditor: React.FC<PredictionEditorProps> = ({
|
||||
tournament,
|
||||
initialPicks,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const matchupSheet = useSheet();
|
||||
const matches = tournament.matches || [];
|
||||
const [picks, setPicks] = useState<PicksMap>(initialPicks);
|
||||
|
||||
const pickable = useMemo(
|
||||
() => getPickableMatches(matches, picks),
|
||||
[matches, picks]
|
||||
);
|
||||
|
||||
const resolved = useMemo(
|
||||
() => resolvePredictedBracket(matches, picks),
|
||||
[matches, picks]
|
||||
);
|
||||
|
||||
const firstUnpickedLid = useMemo(
|
||||
() =>
|
||||
pickable.find((match) => !resolved.get(match.lid)?.pickedWinnerId)?.lid,
|
||||
[pickable, resolved]
|
||||
);
|
||||
|
||||
const [activeLid, setActiveLid] = useState<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (activeLid === undefined && pickable.length > 0) {
|
||||
setActiveLid(firstUnpickedLid ?? pickable[0].lid);
|
||||
}
|
||||
}, [activeLid, firstUnpickedLid, pickable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLid === undefined) return;
|
||||
const card = containerRef.current?.querySelector(
|
||||
`[data-match-lid="${activeLid}"]`
|
||||
) as HTMLElement | null;
|
||||
const viewport = card?.closest(
|
||||
".mantine-ScrollArea-viewport"
|
||||
) as HTMLElement | null;
|
||||
if (!card || !viewport) return;
|
||||
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
viewport.scrollTo({
|
||||
left: Math.max(
|
||||
0,
|
||||
viewport.scrollLeft +
|
||||
(cardRect.left - viewportRect.left) -
|
||||
(viewportRect.width - cardRect.width) / 2
|
||||
),
|
||||
top: Math.max(
|
||||
0,
|
||||
viewport.scrollTop +
|
||||
(cardRect.top - viewportRect.top) -
|
||||
(viewportRect.height - cardRect.height) / 2
|
||||
),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeLid]);
|
||||
|
||||
const pickedCount = pickable.filter(
|
||||
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||
).length;
|
||||
const complete = pickedCount === pickable.length;
|
||||
|
||||
const submit = useSubmitPrediction(tournament.id);
|
||||
|
||||
const handlePick = (lid: number, teamId: string) => {
|
||||
const next = setPick(matches, picks, lid, teamId);
|
||||
setPicks(next);
|
||||
|
||||
const nextResolved = resolvePredictedBracket(matches, next);
|
||||
const nextPickable = getPickableMatches(matches, next);
|
||||
const nextUnpicked = nextPickable.find(
|
||||
(match) => !nextResolved.get(match.lid)?.pickedWinnerId
|
||||
);
|
||||
setActiveLid(nextUnpicked?.lid ?? lid);
|
||||
};
|
||||
|
||||
const activeIndex = pickable.findIndex((match) => match.lid === activeLid);
|
||||
const stepTo = (offset: number) => {
|
||||
const next = pickable[activeIndex + offset];
|
||||
if (next) setActiveLid(next.lid);
|
||||
};
|
||||
|
||||
const activeResolved =
|
||||
activeLid !== undefined ? resolved.get(activeLid) : undefined;
|
||||
const activeMatch = pickable[activeIndex];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
data: { tournamentId: tournament.id, picks },
|
||||
});
|
||||
navigate({ to: "/" });
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box pos="relative" ref={containerRef}>
|
||||
<PredictionBracket
|
||||
matches={matches}
|
||||
picks={picks}
|
||||
mode="edit"
|
||||
activeLid={activeLid}
|
||||
onActivate={setActiveLid}
|
||||
/>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
p="md"
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="lg"
|
||||
p="sm"
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
borderColor: "var(--mantine-primary-color-filled)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{activeMatch && (
|
||||
<>
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<WizardOrbIcon
|
||||
size={16}
|
||||
weight="duotone"
|
||||
color="var(--mantine-primary-color-filled)"
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="var(--mantine-primary-color-filled)"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{getMatchLabel(matches, activeMatch)}
|
||||
</Text>
|
||||
</Group>
|
||||
<WinnerSelector
|
||||
home={activeResolved?.home}
|
||||
away={activeResolved?.away}
|
||||
pickedId={activeResolved?.pickedWinnerId}
|
||||
onSelect={(teamId) => handlePick(activeMatch.lid, teamId)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => stepTo(-1)}
|
||||
disabled={activeIndex <= 0}
|
||||
aria-label="Previous match"
|
||||
>
|
||||
<CaretLeftIcon size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => stepTo(1)}
|
||||
disabled={activeIndex < 0 || activeIndex >= pickable.length - 1}
|
||||
aria-label="Next match"
|
||||
>
|
||||
<CaretRightIcon size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={matchupSheet.open}
|
||||
disabled={!activeResolved?.home && !activeResolved?.away}
|
||||
aria-label="Matchup details"
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={600} c={complete ? undefined : "dimmed"}>
|
||||
{pickedCount}/{pickable.length}
|
||||
</Text>
|
||||
<Button
|
||||
disabled={!complete}
|
||||
loading={submit.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Sheet
|
||||
title={activeMatch ? getMatchLabel(matches, activeMatch) : "Matchup"}
|
||||
{...matchupSheet.props}
|
||||
>
|
||||
<MatchupSheet
|
||||
home={activeResolved?.home}
|
||||
away={activeResolved?.away}
|
||||
isOpen={matchupSheet.isOpen}
|
||||
/>
|
||||
</Sheet>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { CrownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { predictionQueries, usePredictionsLeaderboard } from "../queries";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "../utils";
|
||||
|
||||
interface PredictionLeaderboardProps {
|
||||
tournament: Tournament;
|
||||
}
|
||||
|
||||
export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { data: leaderboard } = usePredictionsLeaderboard(tournament.id);
|
||||
|
||||
const matches = tournament.matches || [];
|
||||
const isComplete = useMemo(() => {
|
||||
const nonByeMatches = matches.filter(
|
||||
(match) => !(match.status === "tbd" && match.bye === true)
|
||||
);
|
||||
return (
|
||||
nonByeMatches.length > 0 &&
|
||||
nonByeMatches.every((match) => match.status === "ended")
|
||||
);
|
||||
}, [matches]);
|
||||
|
||||
const predictionsOpen =
|
||||
isTournamentPredictable(tournament) && !isPredictionLocked(matches);
|
||||
|
||||
const { data: myPrediction } = useServerQuery({
|
||||
...predictionQueries.mine(tournament.id),
|
||||
options: { enabled: !leaderboard.locked && predictionsOpen },
|
||||
});
|
||||
|
||||
if (!leaderboard.locked) {
|
||||
const cta = predictionsOpen ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/tournaments/$id/predictions/make",
|
||||
params: { id: tournament.id },
|
||||
})
|
||||
}
|
||||
>
|
||||
{myPrediction?.prediction
|
||||
? "Edit Your Prediction"
|
||||
: "Make Your Prediction"}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<WizardOrbIcon
|
||||
size={56}
|
||||
weight="duotone"
|
||||
color="var(--mantine-primary-color-filled)"
|
||||
/>
|
||||
<Stack align="center" gap={4}>
|
||||
<Title order={3} c="dimmed" ta="center">
|
||||
Predictions are open
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||
Other players' predictions are hidden until the tournament
|
||||
starts.
|
||||
</Text>
|
||||
</Stack>
|
||||
{cta}
|
||||
</Stack>
|
||||
{leaderboard.submitters.length > 0 && (
|
||||
<>
|
||||
<Text px="md" size="sm" fw={600} pb="xs">
|
||||
{leaderboard.count} bracket{leaderboard.count === 1 ? "" : "s"} in
|
||||
</Text>
|
||||
{leaderboard.submitters.map((player, index) => {
|
||||
const name = `${player.first_name} ${player.last_name}`;
|
||||
return (
|
||||
<Box key={player.id}>
|
||||
<Group gap="sm" align="center" p="md" wrap="nowrap">
|
||||
<PlayerAvatar name={name} size={32} disableFullscreen />
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
{index < leaderboard.submitters.length - 1 && <Divider />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (leaderboard.entries.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<WizardOrbIcon
|
||||
size={56}
|
||||
weight="duotone"
|
||||
color="var(--mantine-primary-color-filled)"
|
||||
/>
|
||||
<Stack align="center" gap={4}>
|
||||
<Title order={3} c="dimmed" ta="center">
|
||||
No predictions
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||
Nobody made a prediction for this tournament.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group px="md" justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="lg" fw={600}>
|
||||
Predictions
|
||||
</Text>
|
||||
<Popover position="bottom-end" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="How prediction scoring works"
|
||||
>
|
||||
<InfoIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Box maw={280}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Prediction Scoring:
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
• Each correct pick earns points, doubling every round
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
• <strong>Winners bracket:</strong> 10, 20, 40, 80…
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
• <strong>Losers bracket:</strong> 5, 10, 20, 40…
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
• <strong>Bracket reset:</strong> only picked if your bracket
|
||||
triggers it — worth double the Final
|
||||
</Text>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Tiebreakers:
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
1. Correct champion pick
|
||||
</Text>
|
||||
<Text size="xs" mb={2}>
|
||||
2. Earlier submission
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
* PICKS shows correct picks / total picks made
|
||||
</Text>
|
||||
</Box>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Text px="md" c="dimmed" size="xs" fw={500}>
|
||||
Correct picks are worth more each round
|
||||
</Text>
|
||||
{leaderboard.entries.map((entry, index) => {
|
||||
const name = `${entry.player.first_name} ${entry.player.last_name}`;
|
||||
|
||||
return (
|
||||
<Box key={entry.player.id}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
style={{ borderRadius: 0 }}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/tournaments/$id/predictions/$playerId",
|
||||
params: { id: tournament.id, playerId: entry.player.id },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center" w="100%" wrap="nowrap">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<PlayerAvatar name={name} size={40} disableFullscreen />
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
#{index + 1}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{index === 0 && isComplete && (
|
||||
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||
<CrownIcon size={12} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
{entry.championPick && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CrownIcon
|
||||
size={12}
|
||||
weight="fill"
|
||||
color={
|
||||
entry.championCorrect && isComplete
|
||||
? "gold"
|
||||
: "var(--mantine-color-dimmed)"
|
||||
}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{entry.championPick.name}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
PTS
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{entry.points}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
PICKS
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.correct}/{entry.total}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{index < leaderboard.entries.length - 1 && <Divider />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Card, Flex, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { MatchSlot, MatchSlotState } from "@/features/bracket/components/match-slot";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { PickResult, ResolvedMatch } from "../utils";
|
||||
|
||||
interface PredictionMatchCardProps {
|
||||
match: Match;
|
||||
resolved?: ResolvedMatch;
|
||||
orders: Record<number, number>;
|
||||
mode: "edit" | "view";
|
||||
result?: PickResult;
|
||||
active?: boolean;
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
const pickedState = (mode: "edit" | "view", result?: PickResult): MatchSlotState => {
|
||||
if (mode === "edit") return "winner";
|
||||
if (result === "correct") return "correct";
|
||||
if (result === "incorrect") return "incorrect";
|
||||
return "winner";
|
||||
};
|
||||
|
||||
export const PredictionMatchCard: React.FC<PredictionMatchCardProps> = ({
|
||||
match,
|
||||
resolved,
|
||||
orders,
|
||||
mode,
|
||||
result,
|
||||
active,
|
||||
onActivate,
|
||||
}) => {
|
||||
const resetLive = !match.reset || !!resolved?.resetNecessary;
|
||||
|
||||
const slotProps = (side: "home" | "away") => {
|
||||
const team = side === "home" ? resolved?.home : resolved?.away;
|
||||
const teamId = side === "home" ? resolved?.homeId : resolved?.awayId;
|
||||
const isPicked =
|
||||
!!teamId && resetLive && resolved?.pickedWinnerId === teamId;
|
||||
|
||||
return {
|
||||
from: orders[side === "home" ? match.home_from_lid : match.away_from_lid],
|
||||
from_loser:
|
||||
side === "home" ? match.home_from_loser : match.away_from_loser,
|
||||
team,
|
||||
seed: side === "home" ? match.home_seed : match.away_seed,
|
||||
state: isPicked ? pickedState(mode, result) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="end"
|
||||
gap={8}
|
||||
opacity={resetLive ? 1 : 0.55}
|
||||
style={{ transition: "opacity 200ms ease" }}
|
||||
>
|
||||
<Text
|
||||
c="dimmed"
|
||||
fw="bolder"
|
||||
px={6}
|
||||
py={2}
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-body)'
|
||||
}}
|
||||
>
|
||||
{match.order}
|
||||
</Text>
|
||||
<Card
|
||||
w={220}
|
||||
withBorder
|
||||
pos="relative"
|
||||
onClick={onActivate}
|
||||
style={{
|
||||
cursor: onActivate ? "pointer" : undefined,
|
||||
overflow: "visible",
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
borderColor: active
|
||||
? 'var(--mantine-primary-color-filled)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
boxShadow: active
|
||||
? '0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover), var(--mantine-shadow-sm)'
|
||||
: 'var(--mantine-shadow-sm)',
|
||||
transition: 'border-color 200ms ease, box-shadow 200ms ease',
|
||||
}}
|
||||
data-match-lid={match.lid}
|
||||
>
|
||||
<Card.Section withBorder p={0}>
|
||||
<MatchSlot {...slotProps("home")} />
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section p={0} mb={-16}>
|
||||
<MatchSlot {...slotProps("away")} />
|
||||
</Card.Section>
|
||||
|
||||
{match.reset && (
|
||||
<Text
|
||||
pos="absolute"
|
||||
top={-20}
|
||||
left={8}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw="bold"
|
||||
>
|
||||
* If necessary
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { CrownIcon } from "@phosphor-icons/react";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
|
||||
interface WinnerSelectorProps {
|
||||
home?: TeamInfo;
|
||||
away?: TeamInfo;
|
||||
pickedId?: string;
|
||||
onSelect: (teamId: string) => void;
|
||||
}
|
||||
|
||||
const TeamChip = ({
|
||||
team,
|
||||
picked,
|
||||
onSelect,
|
||||
}: {
|
||||
team?: TeamInfo;
|
||||
picked: boolean;
|
||||
onSelect: (teamId: string) => void;
|
||||
}) => {
|
||||
if (!team) {
|
||||
return (
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
justify="center"
|
||||
p="6px 10px"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: "1px dashed var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
TBD
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onSelect(team.id)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
aria-pressed={picked}
|
||||
>
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
p="6px 10px"
|
||||
style={{
|
||||
border: `1px solid ${
|
||||
picked
|
||||
? "var(--mantine-color-green-light-color)"
|
||||
: "var(--mantine-color-default-border)"
|
||||
}`,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
backgroundColor: picked
|
||||
? "var(--mantine-color-green-light)"
|
||||
: "transparent",
|
||||
transition: "background-color 200ms ease, border-color 200ms ease",
|
||||
}}
|
||||
>
|
||||
<TeamAvatar team={team} size={26} radius="sm" disableFullscreen />
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
truncate
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
{picked && (
|
||||
<CrownIcon
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{
|
||||
color: "gold",
|
||||
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.3))",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const WinnerSelector: React.FC<WinnerSelectorProps> = ({
|
||||
home,
|
||||
away,
|
||||
pickedId,
|
||||
onSelect,
|
||||
}) => (
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<TeamChip
|
||||
team={home}
|
||||
picked={!!home && pickedId === home.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
vs
|
||||
</Text>
|
||||
<TeamChip
|
||||
team={away}
|
||||
picked={!!away && pickedId === away.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useServerMutation,
|
||||
useServerSuspenseQuery,
|
||||
} from "@/lib/tanstack-query/hooks";
|
||||
import {
|
||||
getMyPrediction,
|
||||
getPlayerPrediction,
|
||||
getPredictionsLeaderboard,
|
||||
submitPrediction,
|
||||
} from "./server";
|
||||
|
||||
export const predictionKeys = {
|
||||
tournament: (tournamentId: string) => ['predictions', tournamentId] as const,
|
||||
mine: (tournamentId: string) => ['predictions', tournamentId, 'mine'] as const,
|
||||
leaderboard: (tournamentId: string) => ['predictions', tournamentId, 'leaderboard'] as const,
|
||||
player: (tournamentId: string, playerId: string) => ['predictions', tournamentId, 'player', playerId] as const,
|
||||
};
|
||||
|
||||
export const predictionQueries = {
|
||||
mine: (tournamentId: string) => ({
|
||||
queryKey: predictionKeys.mine(tournamentId),
|
||||
queryFn: () => getMyPrediction({ data: tournamentId }),
|
||||
}),
|
||||
leaderboard: (tournamentId: string) => ({
|
||||
queryKey: predictionKeys.leaderboard(tournamentId),
|
||||
queryFn: () => getPredictionsLeaderboard({ data: tournamentId }),
|
||||
}),
|
||||
player: (tournamentId: string, playerId: string) => ({
|
||||
queryKey: predictionKeys.player(tournamentId, playerId),
|
||||
queryFn: () => getPlayerPrediction({ data: { tournamentId, playerId } }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useMyPrediction = (tournamentId: string) =>
|
||||
useServerSuspenseQuery(predictionQueries.mine(tournamentId));
|
||||
|
||||
export const usePredictionsLeaderboard = (tournamentId: string) =>
|
||||
useServerSuspenseQuery(predictionQueries.leaderboard(tournamentId));
|
||||
|
||||
export const usePlayerPrediction = (tournamentId: string, playerId: string) =>
|
||||
useServerSuspenseQuery(predictionQueries.player(tournamentId, playerId));
|
||||
|
||||
export const useSubmitPrediction = (tournamentId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: submitPrediction,
|
||||
successMessage: "Prediction saved!",
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: predictionKeys.tournament(tournamentId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import {
|
||||
MyPrediction,
|
||||
Prediction,
|
||||
PredictionLeaderboardEntry,
|
||||
PredictionsLeaderboard,
|
||||
} from "./types";
|
||||
import {
|
||||
computePredictionScore,
|
||||
getPickableMatches,
|
||||
isPredictionComplete,
|
||||
isPredictionLocked,
|
||||
isTournamentPredictable,
|
||||
} from "./utils";
|
||||
|
||||
const getTournamentOrThrow = async (tournamentId: string): Promise<Tournament> => {
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
if (!tournament) {
|
||||
throw new Error("Tournament not found");
|
||||
}
|
||||
return tournament;
|
||||
};
|
||||
|
||||
export const getMyPrediction = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId, context }) =>
|
||||
toServerResult(async (): Promise<MyPrediction> => {
|
||||
const tournament = await getTournamentOrThrow(tournamentId);
|
||||
const matches = tournament.matches || [];
|
||||
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
const prediction = player
|
||||
? await pbAdmin.getPrediction(tournamentId, player.id)
|
||||
: null;
|
||||
|
||||
return {
|
||||
prediction,
|
||||
locked: isPredictionLocked(matches),
|
||||
eligible: isTournamentPredictable(tournament),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const submitPredictionSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
picks: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const submitPrediction = createServerFn()
|
||||
.validator(submitPredictionSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||
toServerResult(async (): Promise<Prediction> => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
if (!player) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
|
||||
const tournament = await getTournamentOrThrow(tournamentId);
|
||||
const matches = tournament.matches || [];
|
||||
|
||||
if (!isTournamentPredictable(tournament)) {
|
||||
throw new Error("Predictions are not available for this tournament");
|
||||
}
|
||||
|
||||
if (isPredictionLocked(matches)) {
|
||||
throw new Error("Predictions are locked — the tournament has started");
|
||||
}
|
||||
|
||||
const pickableLids = new Set(
|
||||
getPickableMatches(matches, picks).map((match) => String(match.lid))
|
||||
);
|
||||
const pickKeys = Object.keys(picks);
|
||||
if (
|
||||
pickKeys.length !== pickableLids.size ||
|
||||
pickKeys.some((lid) => !pickableLids.has(lid))
|
||||
) {
|
||||
throw new Error("Prediction must include a pick for every match");
|
||||
}
|
||||
|
||||
if (!isPredictionComplete(matches, picks)) {
|
||||
throw new Error("Prediction contains invalid picks");
|
||||
}
|
||||
|
||||
const prediction = await pbAdmin.upsertPrediction(
|
||||
tournamentId,
|
||||
player.id,
|
||||
picks
|
||||
);
|
||||
|
||||
logger.info("Prediction submitted", {
|
||||
tournamentId,
|
||||
playerId: player.id,
|
||||
pickCount: pickKeys.length,
|
||||
});
|
||||
|
||||
return prediction;
|
||||
})
|
||||
);
|
||||
|
||||
export const getPredictionsLeaderboard = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(async (): Promise<PredictionsLeaderboard> => {
|
||||
const tournament = await getTournamentOrThrow(tournamentId);
|
||||
const matches = tournament.matches || [];
|
||||
const locked = isPredictionLocked(matches);
|
||||
|
||||
const predictions = await pbAdmin.getPredictionsForTournament(tournamentId);
|
||||
const submitters = predictions.map((prediction) => prediction.player);
|
||||
|
||||
if (!locked) {
|
||||
return { locked, count: predictions.length, entries: [], submitters };
|
||||
}
|
||||
|
||||
const entries: PredictionLeaderboardEntry[] = predictions.map(
|
||||
(prediction) => {
|
||||
const score = computePredictionScore(matches, prediction.picks);
|
||||
const championPick = tournament.teams?.find(
|
||||
(team) => team.id === score.predictedChampionId
|
||||
);
|
||||
|
||||
return {
|
||||
player: prediction.player,
|
||||
points: score.points,
|
||||
correct: score.correct,
|
||||
total: score.total,
|
||||
championPick,
|
||||
championCorrect:
|
||||
!!score.predictedChampionId &&
|
||||
score.predictedChampionId === tournament.first_place?.id,
|
||||
updated: prediction.updated,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
b.points - a.points ||
|
||||
Number(b.championCorrect) - Number(a.championCorrect) ||
|
||||
a.updated.localeCompare(b.updated) ||
|
||||
(a.player.first_name ?? "").localeCompare(b.player.first_name ?? "")
|
||||
);
|
||||
|
||||
return { locked, count: entries.length, entries, submitters };
|
||||
})
|
||||
);
|
||||
|
||||
const playerPredictionSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
playerId: z.string(),
|
||||
});
|
||||
|
||||
export const getPlayerPrediction = createServerFn()
|
||||
.validator(playerPredictionSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, playerId }, context }) =>
|
||||
toServerResult(async (): Promise<Prediction | null> => {
|
||||
const tournament = await getTournamentOrThrow(tournamentId);
|
||||
|
||||
if (!isPredictionLocked(tournament.matches || [])) {
|
||||
const me = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
if (me?.id !== playerId) {
|
||||
throw new Error("Predictions are private until the tournament starts");
|
||||
}
|
||||
}
|
||||
|
||||
return pbAdmin.getPrediction(tournamentId, playerId);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
|
||||
export type PicksMap = Record<string, string>;
|
||||
|
||||
export interface Prediction {
|
||||
id: string;
|
||||
tournament: string;
|
||||
player: PlayerInfo;
|
||||
picks: PicksMap;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface PredictionLeaderboardEntry {
|
||||
player: PlayerInfo;
|
||||
points: number;
|
||||
correct: number;
|
||||
total: number;
|
||||
championPick?: TeamInfo;
|
||||
championCorrect: boolean;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface PredictionsLeaderboard {
|
||||
locked: boolean;
|
||||
count: number;
|
||||
entries: PredictionLeaderboardEntry[];
|
||||
submitters: PlayerInfo[];
|
||||
}
|
||||
|
||||
export interface MyPrediction {
|
||||
prediction: Prediction | null;
|
||||
locked: boolean;
|
||||
eligible: boolean;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { PicksMap } from "./types";
|
||||
|
||||
const teamId = (team?: TeamInfo | Team | string): string | undefined =>
|
||||
typeof team === "string" ? team : team?.id;
|
||||
|
||||
const asTeamInfo = (team?: TeamInfo | Team | string): TeamInfo | undefined =>
|
||||
typeof team === "string" ? undefined : team;
|
||||
|
||||
export interface ResolvedMatch {
|
||||
lid: number;
|
||||
home?: TeamInfo;
|
||||
homeId?: string;
|
||||
away?: TeamInfo;
|
||||
awayId?: string;
|
||||
pickedWinner?: TeamInfo;
|
||||
pickedWinnerId?: string;
|
||||
pickedLoser?: TeamInfo;
|
||||
pickedLoserId?: string;
|
||||
resetNecessary?: boolean;
|
||||
}
|
||||
|
||||
const isBracketMatch = (match: Match) => match.round !== -1 && !match.bye;
|
||||
|
||||
export const getPickableMatches = (
|
||||
matches: Match[],
|
||||
picks: PicksMap
|
||||
): Match[] => {
|
||||
const resolved = resolvePredictedBracket(matches, picks);
|
||||
return matches
|
||||
.filter(
|
||||
(match) =>
|
||||
isBracketMatch(match) &&
|
||||
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||
)
|
||||
.sort((a, b) => a.lid - b.lid);
|
||||
};
|
||||
|
||||
export const isPredictionLocked = (matches: Match[]): boolean =>
|
||||
matches.some(
|
||||
(match) => match.status === "started" || match.status === "ended"
|
||||
);
|
||||
|
||||
export const isTournamentPredictable = (tournament: Tournament): boolean => {
|
||||
const matches = tournament.matches || [];
|
||||
return (
|
||||
!tournament.regional &&
|
||||
matches.length > 0 &&
|
||||
!matches.some((match) => match.round === -1)
|
||||
);
|
||||
};
|
||||
|
||||
const resolveInternal = (
|
||||
matches: Match[],
|
||||
picks: PicksMap,
|
||||
prune: boolean
|
||||
): { resolved: Map<number, ResolvedMatch>; picks: PicksMap } => {
|
||||
const resolved = new Map<number, ResolvedMatch>();
|
||||
const nextPicks: PicksMap = { ...picks };
|
||||
|
||||
const bracketMatches = matches
|
||||
.filter(isBracketMatch)
|
||||
.sort((a, b) => a.lid - b.lid);
|
||||
|
||||
for (const match of bracketMatches) {
|
||||
const entry: ResolvedMatch = { lid: match.lid };
|
||||
|
||||
if (match.home_from_lid === -1) {
|
||||
entry.home = asTeamInfo(match.home);
|
||||
entry.homeId = teamId(match.home);
|
||||
} else {
|
||||
const source = resolved.get(match.home_from_lid);
|
||||
entry.home = match.home_from_loser
|
||||
? source?.pickedLoser
|
||||
: source?.pickedWinner;
|
||||
entry.homeId = match.home_from_loser
|
||||
? source?.pickedLoserId
|
||||
: source?.pickedWinnerId;
|
||||
}
|
||||
|
||||
if (match.away_from_lid === -1) {
|
||||
entry.away = asTeamInfo(match.away);
|
||||
entry.awayId = teamId(match.away);
|
||||
} else {
|
||||
const source = resolved.get(match.away_from_lid);
|
||||
entry.away = match.away_from_loser
|
||||
? source?.pickedLoser
|
||||
: source?.pickedWinner;
|
||||
entry.awayId = match.away_from_loser
|
||||
? source?.pickedLoserId
|
||||
: source?.pickedWinnerId;
|
||||
}
|
||||
|
||||
let pickEligible = true;
|
||||
if (match.reset) {
|
||||
const grandFinal = resolved.get(match.home_from_lid);
|
||||
entry.resetNecessary =
|
||||
!!grandFinal?.pickedWinnerId &&
|
||||
grandFinal.pickedWinnerId === grandFinal.awayId;
|
||||
pickEligible = entry.resetNecessary;
|
||||
}
|
||||
|
||||
const pickId = nextPicks[String(match.lid)];
|
||||
if (pickEligible && pickId && pickId === entry.homeId) {
|
||||
entry.pickedWinner = entry.home;
|
||||
entry.pickedWinnerId = entry.homeId;
|
||||
entry.pickedLoser = entry.away;
|
||||
entry.pickedLoserId = entry.awayId;
|
||||
} else if (pickEligible && pickId && pickId === entry.awayId) {
|
||||
entry.pickedWinner = entry.away;
|
||||
entry.pickedWinnerId = entry.awayId;
|
||||
entry.pickedLoser = entry.home;
|
||||
entry.pickedLoserId = entry.homeId;
|
||||
} else if (pickId && prune) {
|
||||
delete nextPicks[String(match.lid)];
|
||||
}
|
||||
|
||||
resolved.set(match.lid, entry);
|
||||
}
|
||||
|
||||
return { resolved, picks: nextPicks };
|
||||
};
|
||||
|
||||
export const resolvePredictedBracket = (
|
||||
matches: Match[],
|
||||
picks: PicksMap
|
||||
): Map<number, ResolvedMatch> => resolveInternal(matches, picks, false).resolved;
|
||||
|
||||
export const setPick = (
|
||||
matches: Match[],
|
||||
picks: PicksMap,
|
||||
lid: number,
|
||||
pickedTeamId: string
|
||||
): PicksMap =>
|
||||
resolveInternal(
|
||||
matches,
|
||||
{ ...picks, [String(lid)]: pickedTeamId },
|
||||
true
|
||||
).picks;
|
||||
|
||||
export const isPredictionComplete = (
|
||||
matches: Match[],
|
||||
picks: PicksMap
|
||||
): boolean => {
|
||||
const resolved = resolvePredictedBracket(matches, picks);
|
||||
return getPickableMatches(matches, picks).every(
|
||||
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||
);
|
||||
};
|
||||
|
||||
export const getMatchLabel = (matches: Match[], match: Match): string => {
|
||||
if (match.reset) return "Bracket Reset";
|
||||
|
||||
const winners = matches.filter(
|
||||
(m) => isBracketMatch(m) && !m.reset && !m.is_losers_bracket
|
||||
);
|
||||
const grandFinal = winners.reduce(
|
||||
(highest: Match | undefined, current) =>
|
||||
!highest || current.lid > highest.lid ? current : highest,
|
||||
undefined
|
||||
);
|
||||
if (!grandFinal) return `Match ${match.order}`;
|
||||
|
||||
const hasLosersBracket = matches.some(
|
||||
(m) => isBracketMatch(m) && m.is_losers_bracket
|
||||
);
|
||||
|
||||
if (match.lid === grandFinal.lid) return "Final";
|
||||
if (
|
||||
hasLosersBracket &&
|
||||
!match.is_losers_bracket &&
|
||||
grandFinal.home_from_lid === match.lid
|
||||
) {
|
||||
return "Winners Bracket Final";
|
||||
}
|
||||
if (match.is_losers_bracket && grandFinal.away_from_lid === match.lid) {
|
||||
return "Losers Bracket Final";
|
||||
}
|
||||
return `Match ${match.order}`;
|
||||
};
|
||||
|
||||
export type PickResult = "correct" | "incorrect" | "pending";
|
||||
|
||||
export interface PredictionScore {
|
||||
points: number;
|
||||
correct: number;
|
||||
total: number;
|
||||
perMatch: Map<number, PickResult>;
|
||||
predictedChampionId?: string;
|
||||
}
|
||||
|
||||
export const getMatchPoints = (match: Match): number =>
|
||||
(match.is_losers_bracket ? 5 : 10) * 2 ** match.round;
|
||||
|
||||
export const computePredictionScore = (
|
||||
matches: Match[],
|
||||
picks: PicksMap
|
||||
): PredictionScore => {
|
||||
const pickable = getPickableMatches(matches, picks);
|
||||
const perMatch = new Map<number, PickResult>();
|
||||
|
||||
let points = 0;
|
||||
let correct = 0;
|
||||
|
||||
for (const match of pickable) {
|
||||
const pickId = picks[String(match.lid)];
|
||||
|
||||
if (match.status !== "ended") {
|
||||
perMatch.set(match.lid, "pending");
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualWinnerId =
|
||||
match.home_cups > match.away_cups ? teamId(match.home) : teamId(match.away);
|
||||
|
||||
if (pickId && actualWinnerId && pickId === actualWinnerId) {
|
||||
perMatch.set(match.lid, "correct");
|
||||
points += getMatchPoints(match);
|
||||
correct += 1;
|
||||
} else {
|
||||
perMatch.set(match.lid, "incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
const grandFinal = pickable
|
||||
.filter((match) => !match.is_losers_bracket)
|
||||
.at(-1);
|
||||
|
||||
return {
|
||||
points,
|
||||
correct,
|
||||
total: pickable.length,
|
||||
perMatch,
|
||||
predictedChampionId: grandFinal
|
||||
? picks[String(grandFinal.lid)]
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,10 @@ import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||
import { predictionQueries } from "@/features/predictions/queries";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
@@ -41,6 +45,22 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const isPredictable = useMemo(
|
||||
() => isTournamentPredictable(tournament),
|
||||
[tournament]
|
||||
);
|
||||
|
||||
const predictionsLocked = useMemo(
|
||||
() => isPredictionLocked(tournament.matches || []),
|
||||
[tournament.matches]
|
||||
);
|
||||
|
||||
const { data: myPrediction } = useServerQuery({
|
||||
...predictionQueries.mine(tournament.id),
|
||||
options: { enabled: isPredictable && !predictionsLocked },
|
||||
});
|
||||
const hasSubmitted = !!myPrediction?.prediction;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
@@ -99,6 +119,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
/>
|
||||
{isPredictable && !predictionsLocked && (
|
||||
<ListLink
|
||||
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
|
||||
to={`/tournaments/${tournament.id}/predictions/make`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||
<ListLink
|
||||
label={`View Predictions`}
|
||||
to={`/tournaments/${tournament.id}/predictions`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||
<RulesListButton tournamentId={tournament.id} />
|
||||
</Box>
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { isTournamentPredictable } from "@/features/predictions/utils";
|
||||
import { Podium } from "./podium";
|
||||
|
||||
interface TournamentStatsProps {
|
||||
@@ -185,6 +187,13 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
/>
|
||||
{isTournamentPredictable(tournament) && (
|
||||
<ListLink
|
||||
label={`View Predictions`}
|
||||
to={`/tournaments/${tournament.id}/predictions`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
{renderTeamStatsTable()}
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Logger } from "@/lib/logger";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
|
||||
import { predictionKeys } from "@/features/predictions/queries";
|
||||
|
||||
const logger = new Logger('ServerEvents');
|
||||
|
||||
@@ -47,6 +48,7 @@ const eventHandlers: Record<string, EventHandler> = {
|
||||
"match": (event, queryClient) => {
|
||||
debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
|
||||
debouncedInvalidate(queryClient, tournamentQueries.current())
|
||||
debouncedInvalidate(queryClient, { queryKey: predictionKeys.tournament(event.tournamentId) })
|
||||
},
|
||||
"reaction": (event, queryClient) => {
|
||||
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createReactionsService } from "./services/reactions";
|
||||
import { createActivitiesService } from "./services/activities";
|
||||
import { createBadgesService } from "./services/badges";
|
||||
import { createGroupsService } from "./services/groups";
|
||||
import { createPredictionsService } from "./services/predictions";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -48,6 +49,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createActivitiesService(this.pb));
|
||||
Object.assign(this, createBadgesService(this.pb));
|
||||
Object.assign(this, createGroupsService(this.pb));
|
||||
Object.assign(this, createPredictionsService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise.then(() => {
|
||||
@@ -126,7 +128,8 @@ interface AdminClient
|
||||
ReturnType<typeof createReactionsService>,
|
||||
ReturnType<typeof createActivitiesService>,
|
||||
ReturnType<typeof createBadgesService>,
|
||||
ReturnType<typeof createGroupsService> {
|
||||
ReturnType<typeof createGroupsService>,
|
||||
ReturnType<typeof createPredictionsService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PicksMap, Prediction } from "@/features/predictions/types";
|
||||
import { transformPrediction } from "../util/transform-types";
|
||||
|
||||
export function createPredictionsService(pb: PocketBase) {
|
||||
return {
|
||||
async getPrediction(
|
||||
tournamentId: string,
|
||||
playerId: string
|
||||
): Promise<Prediction | null> {
|
||||
try {
|
||||
const record = await pb
|
||||
.collection("predictions")
|
||||
.getFirstListItem(
|
||||
`tournament="${tournamentId}" && player="${playerId}"`,
|
||||
{ expand: "player" }
|
||||
);
|
||||
return transformPrediction(record);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getPredictionsForTournament(
|
||||
tournamentId: string
|
||||
): Promise<Prediction[]> {
|
||||
const records = await pb.collection("predictions").getFullList({
|
||||
filter: `tournament="${tournamentId}"`,
|
||||
expand: "player",
|
||||
sort: "updated",
|
||||
});
|
||||
return records.map(transformPrediction);
|
||||
},
|
||||
|
||||
async upsertPrediction(
|
||||
tournamentId: string,
|
||||
playerId: string,
|
||||
picks: PicksMap
|
||||
): Promise<Prediction> {
|
||||
const existing = await this.getPrediction(tournamentId, playerId);
|
||||
|
||||
const record = existing
|
||||
? await pb
|
||||
.collection("predictions")
|
||||
.update(existing.id, { picks }, { expand: "player" })
|
||||
: await pb.collection("predictions").create(
|
||||
{
|
||||
tournament: tournamentId,
|
||||
player: playerId,
|
||||
picks,
|
||||
},
|
||||
{ expand: "player" }
|
||||
);
|
||||
|
||||
return transformPrediction(record);
|
||||
},
|
||||
|
||||
async deletePredictionsForTournament(tournamentId: string): Promise<void> {
|
||||
const records = await pb.collection("predictions").getFullList({
|
||||
filter: `tournament="${tournamentId}"`,
|
||||
fields: "id",
|
||||
});
|
||||
for (const record of records) {
|
||||
await pb.collection("predictions").delete(record.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Player, PlayerInfo } from "@/features/players/types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||
import { Prediction } from "@/features/predictions/types";
|
||||
import { Activity } from "../services/activities";
|
||||
|
||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||
@@ -295,6 +296,17 @@ export function transformReaction(record: any) {
|
||||
};
|
||||
}
|
||||
|
||||
export function transformPrediction(record: any): Prediction {
|
||||
return {
|
||||
id: record.id,
|
||||
tournament: record.tournament,
|
||||
player: transformPlayerInfo(record.expand?.player ?? { id: record.player }),
|
||||
picks: record.picks || {},
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformBadgeInfo(record: any): BadgeInfo {
|
||||
return {
|
||||
id: record.id,
|
||||
|
||||
Reference in New Issue
Block a user