Merge branch 'predictions' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s

This commit is contained in:
yohlo
2026-07-14 14:22:50 -07:00
28 changed files with 2087 additions and 91 deletions
@@ -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);
})
+68
View File
@@ -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 AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' 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 AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' 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 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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
@@ -193,6 +196,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
path: '/tournaments/$id/predictions',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdGroupsRoute = const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({ AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups', id: '/tournaments/$id/groups',
@@ -217,6 +226,18 @@ const ApiFilesCollectionRecordIdFileRoute =
path: '/api/files/$collection/$recordId/$file', path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
@@ -266,10 +287,13 @@ export interface FileRoutesByFullPath {
'/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/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 '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -302,10 +326,13 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/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 '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -341,10 +368,13 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_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 '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -380,10 +410,13 @@ export interface FileRouteTypes {
| '/tournaments/' | '/tournaments/'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments/' | '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id/' | '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -416,10 +449,13 @@ export interface FileRouteTypes {
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
id: id:
@@ -454,10 +490,13 @@ export interface FileRouteTypes {
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups' | '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
| '/_authed/tournaments/$id/predictions_/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/' | '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -686,6 +725,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute 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': { '/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups' id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups' path: '/tournaments/$id/groups'
@@ -714,6 +760,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport 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': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -779,6 +839,9 @@ interface AuthedRouteChildren {
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
} }
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
@@ -793,6 +856,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
AuthedTournamentsIdPredictionsPlayerIdRoute:
AuthedTournamentsIdPredictionsPlayerIdRoute,
AuthedTournamentsIdPredictionsMakeRoute:
AuthedTournamentsIdPredictionsMakeRoute,
} }
const AuthedRouteWithChildren = const AuthedRouteWithChildren =
@@ -4,11 +4,12 @@ import {
useTournament, useTournament,
} from "@/features/tournaments/queries"; } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; 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 { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; 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 BracketView from "@/features/bracket/components/bracket-view";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
pendingComponent: BracketPending, 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() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const bracket: BracketData = useMemo(() => { const bracket: BracketData = useMemo(
if (!tournament.matches || tournament.matches.length === 0) { () => groupMatchesIntoBracket(tournament.matches),
return { winners: [], losers: [] }; [tournament.matches]
} );
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]);
return ( return (
<Container size="md" px={0}> <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>
);
}
+58
View File
@@ -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>
);
}
@@ -14,9 +14,11 @@ interface BracketViewProps {
num_groups: number; num_groups: number;
advance_per_group: 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 height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false); const hasAutoScrolled = useRef(false);
@@ -93,16 +95,17 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Winners Bracket Winners Bracket
</Text> </Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} /> <Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div> </div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && ( {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div> <div>
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Losers Bracket Losers Bracket
</Text> </Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} /> <Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div> </div>
)} )}
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
</ScrollArea> </ScrollArea>
<MatchDock match={selectedMatch} onClose={closeDock} /> <MatchDock match={selectedMatch} onClose={closeDock} />
</Box> </Box>
@@ -11,6 +11,7 @@ interface BracketProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
renderMatch?: (match: Match) => React.ReactNode;
onMatchTap?: (match: Match) => void; onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null; selectedMatchLid?: number | null;
} }
@@ -20,6 +21,7 @@ export const Bracket: React.FC<BracketProps> = ({
orders, orders,
showControls, showControls,
groupConfig, groupConfig,
renderMatch,
onMatchTap, onMatchTap,
selectedMatchLid, selectedMatchLid,
}) => { }) => {
@@ -137,6 +139,9 @@ export const Bracket: React.FC<BracketProps> = ({
<div key={match.lid}></div> <div key={match.lid}></div>
) : ( ) : (
<div key={match.lid}> <div key={match.lid}>
{renderMatch ? (
renderMatch(match)
) : (
<MatchCard <MatchCard
match={match} match={match}
orders={orders} orders={orders}
@@ -145,6 +150,7 @@ export const Bracket: React.FC<BracketProps> = ({
onTap={onMatchTap} onTap={onMatchTap}
selected={selectedMatchLid === match.lid} selected={selectedMatchLid === match.lid}
/> />
)}
</div> </div>
) )
)} )}
+16 -3
View File
@@ -6,6 +6,8 @@ import { TeamInfo } from "@/features/teams/types";
import AnimatedScore from "@/features/matches/components/animated-score"; import AnimatedScore from "@/features/matches/components/animated-score";
import classes from "./match-slot.module.css"; import classes from "./match-slot.module.css";
export type MatchSlotState = "winner" | "correct" | "incorrect";
interface MatchSlotProps { interface MatchSlotProps {
from?: number; from?: number;
from_loser?: boolean; from_loser?: boolean;
@@ -14,6 +16,7 @@ interface MatchSlotProps {
cups?: number; cups?: number;
isWinner?: boolean; isWinner?: boolean;
groupLabel?: string; groupLabel?: string;
state?: MatchSlotState;
} }
export const MatchSlot: React.FC<MatchSlotProps> = ({ export const MatchSlot: React.FC<MatchSlotProps> = ({
@@ -23,7 +26,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
seed, seed,
cups, cups,
isWinner, isWinner,
groupLabel groupLabel,
state,
}) => { }) => {
const teamId = team?.id; const teamId = team?.id;
const previousTeamIdRef = useRef<string | undefined>(teamId); const previousTeamIdRef = useRef<string | undefined>(teamId);
@@ -37,11 +41,19 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
} }
}, [teamId]); }, [teamId]);
const slotState: MatchSlotState | undefined =
state ?? (isWinner ? "winner" : undefined);
const highlighted = slotState === "winner" || slotState === "correct";
return ( return (
<Flex <Flex
align="stretch" align="stretch"
style={{ 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)', borderRadius: 'var(--mantine-radius-sm)',
transition: 'background-color 200ms ease', transition: 'background-color 200ms ease',
}} }}
@@ -60,11 +72,12 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
<Text <Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate truncate
c={slotState === "incorrect" ? "dimmed" : undefined}
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
> >
{team.name} {team.name}
</Text> </Text>
{isWinner && ( {highlighted && (
<CrownIcon <CrownIcon
size={14} size={14}
weight="fill" weight="fill"
+37
View File
@@ -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>
);
+56
View File
@@ -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),
});
},
});
};
+179
View File
@@ -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);
})
);
+36
View File
@@ -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;
}
+240
View File
@@ -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,
};
};
@@ -6,6 +6,10 @@ import { Carousel } from "@mantine/carousel";
import carouselClasses from "./carousel.module.css"; import carouselClasses from "./carousel.module.css";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react"; 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 TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button"; import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card"; import MatchCard from "@/features/matches/components/match-card";
@@ -42,6 +46,22 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
return tournament.matches?.some((match) => match.round === -1) || false; return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]); }, [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 ( return (
<Stack gap="lg"> <Stack gap="lg">
<Header tournament={tournament} /> <Header tournament={tournament} />
@@ -117,6 +137,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
to={`/tournaments/${tournament.id}/bracket`} to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon} 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} /> <TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
<RulesListButton tournamentId={tournament.id} /> <RulesListButton tournamentId={tournament.id} />
</Box> </Box>
@@ -13,8 +13,10 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { Tournament } from "@/features/tournaments/types"; import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react"; import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { isTournamentPredictable } from "@/features/predictions/utils";
import { Podium } from "./podium"; import { Podium } from "./podium";
interface TournamentStatsProps { interface TournamentStatsProps {
@@ -185,6 +187,13 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
to={`/tournaments/${tournament.id}/bracket`} to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon} Icon={TreeStructureIcon}
/> />
{isTournamentPredictable(tournament) && (
<ListLink
label={`View Predictions`}
to={`/tournaments/${tournament.id}/predictions`}
Icon={WizardOrbIcon}
/>
)}
{renderTeamStatsTable()} {renderTeamStatsTable()}
</Stack> </Stack>
</Container> </Container>
+2
View File
@@ -4,6 +4,7 @@ import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { reactionKeys } from "@/features/reactions/queries"; import { reactionKeys } from "@/features/reactions/queries";
import { predictionKeys } from "@/features/predictions/queries";
const logger = new Logger('ServerEvents'); const logger = new Logger('ServerEvents');
@@ -54,6 +55,7 @@ const eventHandlers: Record<string, EventHandler> = {
"match": (event, queryClient) => { "match": (event, queryClient) => {
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.details(event.tournamentId) }); debouncedInvalidate(queryClient, { queryKey: tournamentKeys.details(event.tournamentId) });
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.current }); debouncedInvalidate(queryClient, { queryKey: tournamentKeys.current });
debouncedInvalidate(queryClient, { queryKey: predictionKeys.tournament(event.tournamentId) });
debouncedInvalidate(queryClient, { queryKey: ['players', 'stats'] }); debouncedInvalidate(queryClient, { queryKey: ['players', 'stats'] });
debouncedInvalidate(queryClient, { queryKey: ['players', 'matches'] }); debouncedInvalidate(queryClient, { queryKey: ['players', 'matches'] });
debouncedInvalidate(queryClient, { queryKey: ['players', 'activity'] }); debouncedInvalidate(queryClient, { queryKey: ['players', 'activity'] });
+4 -1
View File
@@ -7,6 +7,7 @@ import { createReactionsService } from "./services/reactions";
import { createActivitiesService } from "./services/activities"; import { createActivitiesService } from "./services/activities";
import { createBadgesService } from "./services/badges"; import { createBadgesService } from "./services/badges";
import { createGroupsService } from "./services/groups"; import { createGroupsService } from "./services/groups";
import { createPredictionsService } from "./services/predictions";
class PocketBaseAdminClient { class PocketBaseAdminClient {
private pb: PocketBase; private pb: PocketBase;
@@ -48,6 +49,7 @@ class PocketBaseAdminClient {
Object.assign(this, createActivitiesService(this.pb)); Object.assign(this, createActivitiesService(this.pb));
Object.assign(this, createBadgesService(this.pb)); Object.assign(this, createBadgesService(this.pb));
Object.assign(this, createGroupsService(this.pb)); Object.assign(this, createGroupsService(this.pb));
Object.assign(this, createPredictionsService(this.pb));
this.authPromise = this.authenticate(); this.authPromise = this.authenticate();
this.authPromise.then(() => { this.authPromise.then(() => {
@@ -126,7 +128,8 @@ interface AdminClient
ReturnType<typeof createReactionsService>, ReturnType<typeof createReactionsService>,
ReturnType<typeof createActivitiesService>, ReturnType<typeof createActivitiesService>,
ReturnType<typeof createBadgesService>, ReturnType<typeof createBadgesService>,
ReturnType<typeof createGroupsService> { ReturnType<typeof createGroupsService>,
ReturnType<typeof createPredictionsService> {
authPromise: Promise<void>; 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 { Team, TeamInfo } from "@/features/teams/types";
import { Tournament, TournamentInfo } from "@/features/tournaments/types"; import { Tournament, TournamentInfo } from "@/features/tournaments/types";
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types"; import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
import { Prediction } from "@/features/predictions/types";
import { Activity } from "../services/activities"; import { Activity } from "../services/activities";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field // 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 { export function transformBadgeInfo(record: any): BadgeInfo {
return { return {
id: record.id, id: record.id,