diff --git a/pb_migrations/1784007613_created_predictions.js b/pb_migrations/1784007613_created_predictions.js new file mode 100644 index 0000000..ed99762 --- /dev/null +++ b/pb_migrations/1784007613_created_predictions.js @@ -0,0 +1,95 @@ +/// +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); +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 6ea2f8c..95862f1 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -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 = diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx index 91ca053..0c88d7c 100644 --- a/src/app/routes/_authed/tournaments/$id.bracket.tsx +++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx @@ -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 ( - - - - - {columns.map((count, columnIndex) => ( - - {Array.from({ length: count }).map((_, matchIndex) => ( - - ))} - - ))} - - - - ); -} - 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(); - const losersMap = new Map(); - - 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 ( diff --git a/src/app/routes/_authed/tournaments/$id.predictions.tsx b/src/app/routes/_authed/tournaments/$id.predictions.tsx new file mode 100644 index 0000000..5a72491 --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.predictions.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/routes/_authed/tournaments/$id.predictions_.$playerId.tsx b/src/app/routes/_authed/tournaments/$id.predictions_.$playerId.tsx new file mode 100644 index 0000000..15141f1 --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.predictions_.$playerId.tsx @@ -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 ( + + + + + + + + + + + {prediction?.player.first_name} {prediction?.player.last_name} + + + + + + PTS + + + {score.points} + + + + + PICKS + + + {score.correct}/{score.total} + + + + + + + + + ); +} diff --git a/src/app/routes/_authed/tournaments/$id.predictions_.make.tsx b/src/app/routes/_authed/tournaments/$id.predictions_.make.tsx new file mode 100644 index 0000000..63bf38e --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.predictions_.make.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/wizard-orb-icon.tsx b/src/components/wizard-orb-icon.tsx new file mode 100644 index 0000000..b183ad0 --- /dev/null +++ b/src/components/wizard-orb-icon.tsx @@ -0,0 +1,58 @@ +import { forwardRef } from "react"; +import type { Icon, IconProps } from "@phosphor-icons/react"; + +export const WizardOrbIcon = forwardRef( + ( + { size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest }, + ref + ) => { + const strokeWidth = weight === "bold" ? 20 : 16; + + return ( + + + + + + + ); + } +) as Icon; + +export default WizardOrbIcon; diff --git a/src/features/bracket/components/bracket-pending.tsx b/src/features/bracket/components/bracket-pending.tsx new file mode 100644 index 0000000..7f6f788 --- /dev/null +++ b/src/features/bracket/components/bracket-pending.tsx @@ -0,0 +1,41 @@ +import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core"; + +export function BracketPending() { + const columns = [4, 2, 1]; + + return ( + + + + + {columns.map((count, columnIndex) => ( + + {Array.from({ length: count }).map((_, matchIndex) => ( + + ))} + + ))} + + + + ); +} diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 6920fc7..6686c1e 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -13,9 +13,11 @@ interface BracketViewProps { num_groups: number; advance_per_group: number; }; + renderMatch?: (match: Match) => React.ReactNode; + bottomOffset?: number; } -const BracketView: React.FC = ({ bracket, showControls, groupConfig }) => { +const BracketView: React.FC = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => { const height = useAppShellHeight(); const orders = useMemo(() => { const map: Record = {}; @@ -37,16 +39,17 @@ const BracketView: React.FC = ({ bracket, showControls, groupC Winners Bracket - + {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
Losers Bracket - +
)} + {bottomOffset ?
: null} }; diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx index e038482..a90cefd 100644 --- a/src/features/bracket/components/bracket.tsx +++ b/src/features/bracket/components/bracket.tsx @@ -11,6 +11,7 @@ interface BracketProps { num_groups: number; advance_per_group: number; }; + renderMatch?: (match: Match) => React.ReactNode; } export const Bracket: React.FC = ({ @@ -18,6 +19,7 @@ export const Bracket: React.FC = ({ orders, showControls, groupConfig, + renderMatch, }) => { const containerRef = useRef(null); const svgRef = useRef(null); @@ -133,12 +135,16 @@ export const Bracket: React.FC = ({
) : (
- + {renderMatch ? ( + renderMatch(match) + ) : ( + + )}
) )} diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx index f857db3..205822a 100644 --- a/src/features/bracket/components/match-slot.tsx +++ b/src/features/bracket/components/match-slot.tsx @@ -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 = ({ @@ -23,7 +26,8 @@ export const MatchSlot: React.FC = ({ seed, cups, isWinner, - groupLabel + groupLabel, + state, }) => { const teamId = team?.id; const previousTeamIdRef = useRef(teamId); @@ -37,11 +41,19 @@ export const MatchSlot: React.FC = ({ } }, [teamId]); + const slotState: MatchSlotState | undefined = + state ?? (isWinner ? "winner" : undefined); + const highlighted = slotState === "winner" || slotState === "correct"; + return ( = ({ 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} truncate + c={slotState === "incorrect" ? "dimmed" : undefined} style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} > {team.name} - {isWinner && ( + {highlighted && ( { + if (!matches || matches.length === 0) { + return { winners: [], losers: [] }; + } + + const winnersMap = new Map(); + const losersMap = new Map(); + + 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 }; +}; diff --git a/src/features/predictions/components/matchup-sheet.tsx b/src/features/predictions/components/matchup-sheet.tsx new file mode 100644 index 0000000..a294902 --- /dev/null +++ b/src/features/predictions/components/matchup-sheet.tsx @@ -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 }) => ( + + + + + {team.name} + + + + {team.players?.map((player) => { + const name = `${player.first_name} ${player.last_name}`; + return ( + + + {name} + + + + ); + })} + + +); + +export const MatchupSheet: React.FC = ({ + home, + away, + isOpen, +}) => { + if (!home && !away) { + return ( + + Pick the earlier matches first — these teams aren't decided yet. + + ); + } + + return ( + + + + {home ? ( + + ) : ( + + Home team TBD — pick the earlier matches first + + )} + + {away ? ( + + ) : ( + + Away team TBD — pick the earlier matches first + + )} + + + + {home && away && ( + + )} + + ); +}; diff --git a/src/features/predictions/components/prediction-bracket.tsx b/src/features/predictions/components/prediction-bracket.tsx new file mode 100644 index 0000000..fb9a2f5 --- /dev/null +++ b/src/features/predictions/components/prediction-bracket.tsx @@ -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; + activeLid?: number; + onActivate?: (lid: number) => void; +} + +export const PredictionBracket: React.FC = ({ + 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 = {}; + bracket.winners.flat().forEach((match) => (map[match.lid] = match.order)); + bracket.losers.flat().forEach((match) => (map[match.lid] = match.order)); + return map; + }, [bracket]); + + return ( + ( + onActivate(match.lid) + : undefined + } + /> + )} + /> + ); +}; diff --git a/src/features/predictions/components/prediction-editor.tsx b/src/features/predictions/components/prediction-editor.tsx new file mode 100644 index 0000000..bcd85ac --- /dev/null +++ b/src/features/predictions/components/prediction-editor.tsx @@ -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 = ({ + tournament, + initialPicks, +}) => { + const navigate = useNavigate(); + const containerRef = useRef(null); + const matchupSheet = useSheet(); + const matches = tournament.matches || []; + const [picks, setPicks] = useState(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(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 ( + + + + + + + {activeMatch && ( + <> + + + + {getMatchLabel(matches, activeMatch)} + + + handlePick(activeMatch.lid, teamId)} + /> + + )} + + + stepTo(-1)} + disabled={activeIndex <= 0} + aria-label="Previous match" + > + + + stepTo(1)} + disabled={activeIndex < 0 || activeIndex >= pickable.length - 1} + aria-label="Next match" + > + + + + + + + + + {pickedCount}/{pickable.length} + + + + + + + + + + + + + ); +}; diff --git a/src/features/predictions/components/prediction-leaderboard.tsx b/src/features/predictions/components/prediction-leaderboard.tsx new file mode 100644 index 0000000..23af7e5 --- /dev/null +++ b/src/features/predictions/components/prediction-leaderboard.tsx @@ -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 = ({ + 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 ? ( + + ) : undefined; + + return ( + + + + + + Predictions are open + + + Other players' predictions are hidden until the tournament + starts. + + + {cta} + + {leaderboard.submitters.length > 0 && ( + <> + + {leaderboard.count} bracket{leaderboard.count === 1 ? "" : "s"} in + + {leaderboard.submitters.map((player, index) => { + const name = `${player.first_name} ${player.last_name}`; + return ( + + + + + {name} + + + {index < leaderboard.submitters.length - 1 && } + + ); + })} + + )} + + ); + } + + if (leaderboard.entries.length === 0) { + return ( + + + + + No predictions + + + Nobody made a prediction for this tournament. + + + + ); + } + + return ( + + + + Predictions + + + + + + + + + + + Prediction Scoring: + + + • Each correct pick earns points, doubling every round + + + • Winners bracket: 10, 20, 40, 80… + + + • Losers bracket: 5, 10, 20, 40… + + + • Bracket reset: only picked if your bracket + triggers it — worth double the Final + + + + + + Tiebreakers: + + + 1. Correct champion pick + + + 2. Earlier submission + + + * PICKS shows correct picks / total picks made + + + + + + + Correct picks are worth more each round + + {leaderboard.entries.map((entry, index) => { + const name = `${entry.player.first_name} ${entry.player.last_name}`; + + return ( + + + navigate({ + to: "/tournaments/$id/predictions/$playerId", + params: { id: tournament.id, playerId: entry.player.id }, + }) + } + > + + + + + + + #{index + 1} + + + {name} + + {index === 0 && isComplete && ( + + + + )} + + {entry.championPick && ( + + + + {entry.championPick.name} + + + )} + + + + + + PTS + + + {entry.points} + + + + + PICKS + + + {entry.correct}/{entry.total} + + + + + + {index < leaderboard.entries.length - 1 && } + + ); + })} + + ); +}; diff --git a/src/features/predictions/components/prediction-match-card.tsx b/src/features/predictions/components/prediction-match-card.tsx new file mode 100644 index 0000000..4285c0a --- /dev/null +++ b/src/features/predictions/components/prediction-match-card.tsx @@ -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; + 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 = ({ + 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 ( + + + {match.order} + + + + + + + + + + + {match.reset && ( + + * If necessary + + )} + + + ); +}; diff --git a/src/features/predictions/components/winner-selector.tsx b/src/features/predictions/components/winner-selector.tsx new file mode 100644 index 0000000..e01ae24 --- /dev/null +++ b/src/features/predictions/components/winner-selector.tsx @@ -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 ( + + + TBD + + + ); + } + + return ( + onSelect(team.id)} + style={{ flex: 1, minWidth: 0 }} + aria-pressed={picked} + > + + + + {team.name} + + {picked && ( + + )} + + + ); +}; + +export const WinnerSelector: React.FC = ({ + home, + away, + pickedId, + onSelect, +}) => ( + + + + vs + + + +); diff --git a/src/features/predictions/queries.ts b/src/features/predictions/queries.ts new file mode 100644 index 0000000..807aa0b --- /dev/null +++ b/src/features/predictions/queries.ts @@ -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), + }); + }, + }); +}; diff --git a/src/features/predictions/server.ts b/src/features/predictions/server.ts new file mode 100644 index 0000000..9806408 --- /dev/null +++ b/src/features/predictions/server.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); + }) + ); diff --git a/src/features/predictions/types.ts b/src/features/predictions/types.ts new file mode 100644 index 0000000..52fc746 --- /dev/null +++ b/src/features/predictions/types.ts @@ -0,0 +1,36 @@ +import { PlayerInfo } from "@/features/players/types"; +import { TeamInfo } from "@/features/teams/types"; + +export type PicksMap = Record; + +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; +} diff --git a/src/features/predictions/utils.ts b/src/features/predictions/utils.ts new file mode 100644 index 0000000..8fe2ff1 --- /dev/null +++ b/src/features/predictions/utils.ts @@ -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; picks: PicksMap } => { + const resolved = new Map(); + 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 => 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; + 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(); + + 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, + }; +}; diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx index 7a3489a..87ce7b9 100644 --- a/src/features/tournaments/components/started-tournament/index.tsx +++ b/src/features/tournaments/components/started-tournament/index.tsx @@ -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 (
@@ -99,6 +119,20 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({ to={`/tournaments/${tournament.id}/bracket`} Icon={TreeStructureIcon} /> + {isPredictable && !predictionsLocked && ( + + )} + {isPredictable && (predictionsLocked || hasSubmitted) && ( + + )} diff --git a/src/features/tournaments/components/tournament-stats.tsx b/src/features/tournaments/components/tournament-stats.tsx index 8d3e2a5..e875ac4 100644 --- a/src/features/tournaments/components/tournament-stats.tsx +++ b/src/features/tournaments/components/tournament-stats.tsx @@ -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) && ( + + )} {renderTeamStatsTable()} diff --git a/src/hooks/use-server-events.ts b/src/hooks/use-server-events.ts index 2fe15ba..f54ec7c 100644 --- a/src/hooks/use-server-events.ts +++ b/src/hooks/use-server-events.ts @@ -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 = { "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)); diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts index b8d0a56..8e61df8 100644 --- a/src/lib/pocketbase/client.ts +++ b/src/lib/pocketbase/client.ts @@ -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, ReturnType, ReturnType, - ReturnType { + ReturnType, + ReturnType { authPromise: Promise; } diff --git a/src/lib/pocketbase/services/predictions.ts b/src/lib/pocketbase/services/predictions.ts new file mode 100644 index 0000000..2d0b384 --- /dev/null +++ b/src/lib/pocketbase/services/predictions.ts @@ -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 { + 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 { + 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 { + 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 { + const records = await pb.collection("predictions").getFullList({ + filter: `tournament="${tournamentId}"`, + fields: "id", + }); + for (const record of records) { + await pb.collection("predictions").delete(record.id); + } + }, + }; +} diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index cce0b69..900ccdd 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -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,