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
+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 AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
@@ -193,6 +196,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
path: '/tournaments/$id/predictions',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups',
@@ -217,6 +226,18 @@ const ApiFilesCollectionRecordIdFileRoute =
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedTournamentsIdPredictionsMakeRoute =
AuthedTournamentsIdPredictionsMakeRouteImport.update({
id: '/tournaments/$id/predictions_/make',
path: '/tournaments/$id/predictions/make',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdPredictionsPlayerIdRoute =
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
id: '/tournaments/$id/predictions_/$playerId',
path: '/tournaments/$id/predictions/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id',
@@ -266,10 +287,13 @@ export interface FileRoutesByFullPath {
'/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
@@ -302,10 +326,13 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
@@ -341,10 +368,13 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
@@ -380,10 +410,13 @@ export interface FileRouteTypes {
| '/tournaments/'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo
@@ -416,10 +449,13 @@ export interface FileRouteTypes {
| '/tournaments'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
id:
@@ -454,10 +490,13 @@ export interface FileRouteTypes {
| '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
| '/_authed/tournaments/$id/predictions_/make'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById
@@ -686,6 +725,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions'
fullPath: '/tournaments/$id/predictions'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups'
@@ -714,6 +760,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/tournaments/$id/predictions_/make': {
id: '/_authed/tournaments/$id/predictions_/make'
path: '/tournaments/$id/predictions/make'
fullPath: '/tournaments/$id/predictions/make'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/predictions_/$playerId': {
id: '/_authed/tournaments/$id/predictions_/$playerId'
path: '/tournaments/$id/predictions/$playerId'
fullPath: '/tournaments/$id/predictions/$playerId'
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id'
@@ -779,6 +839,9 @@ interface AuthedRouteChildren {
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
@@ -793,6 +856,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
AuthedTournamentsIdPredictionsPlayerIdRoute:
AuthedTournamentsIdPredictionsPlayerIdRoute,
AuthedTournamentsIdPredictionsMakeRoute:
AuthedTournamentsIdPredictionsMakeRoute,
}
const AuthedRouteWithChildren =
@@ -4,11 +4,12 @@ import {
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
import { Container } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
import BracketView from "@/features/bracket/components/bracket-view";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => {
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
pendingComponent: BracketPending,
});
function BracketPending() {
const columns = [4, 2, 1];
return (
<Container size="md" px={0}>
<Box
p={0}
style={{
overflow: "hidden",
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
minHeight: "70dvh",
}}
>
<Skeleton height={18} width={140} radius="sm" m={16} />
<Flex gap="xl" px={16} align="stretch">
{columns.map((count, columnIndex) => (
<Stack
key={`bracket-pending-round-${columnIndex}`}
gap="xl"
justify="space-around"
style={{ opacity: 1 - columnIndex * 0.25 }}
>
{Array.from({ length: count }).map((_, matchIndex) => (
<Skeleton
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
height={84}
width={220}
radius="md"
/>
))}
</Stack>
))}
</Flex>
</Box>
</Container>
);
}
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
const bracket: BracketData = useMemo(
() => groupMatchesIntoBracket(tournament.matches),
[tournament.matches]
);
return (
<Container size="md" px={0}>
@@ -0,0 +1,37 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core";
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
return {
tournament,
};
},
loader: () => ({
header: {
withBackButton: true,
title: "Predictions",
},
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
return (
<Container size="md" px={0}>
<PredictionLeaderboard tournament={tournament} />
</Container>
);
}
@@ -0,0 +1,124 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useMemo } from "react";
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
import { computePredictionScore } from "@/features/predictions/utils";
import PlayerAvatar from "@/components/player-avatar";
export const Route = createFileRoute(
"/_authed/tournaments/$id/predictions_/$playerId"
)({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
const prediction = await ensureServerQueryData(
queryClient,
predictionQueries.player(params.id, params.playerId)
);
if (!prediction) {
throw redirect({
to: "/tournaments/$id/predictions",
params: { id: params.id },
});
}
return {
tournament,
prediction,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
header: {
withBackButton: true,
title: `${context.prediction.player.first_name}'s Bracket`,
},
}),
component: RouteComponent,
pendingComponent: BracketPending,
});
function RouteComponent() {
const { id, playerId } = Route.useParams();
const { data: tournament } = useTournament(id);
const { data: prediction } = usePlayerPrediction(id, playerId);
const matches = tournament.matches || [];
const picks = prediction?.picks ?? {};
const score = useMemo(
() => computePredictionScore(matches, picks),
[matches, picks]
);
return (
<Container size="md" px={0}>
<Box pos="relative">
<PredictionBracket
matches={matches}
picks={picks}
mode="view"
perMatch={score.perMatch}
/>
<Box
pos="absolute"
left={0}
right={0}
bottom={0}
p="md"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Paper
withBorder
shadow="md"
radius="lg"
p="sm"
style={{ pointerEvents: "auto" }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="center" wrap="nowrap">
<PlayerAvatar
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
size={32}
disableFullscreen
/>
<Text size="sm" fw={600} lineClamp={1}>
{prediction?.player.first_name} {prediction?.player.last_name}
</Text>
</Group>
<Group gap="md" wrap="nowrap">
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PTS
</Text>
<Text size="sm" fw={700}>
{score.points}
</Text>
</Stack>
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PICKS
</Text>
<Text size="xs" c="dimmed">
{score.correct}/{score.total}
</Text>
</Stack>
</Group>
</Group>
</Paper>
</Box>
</Box>
</Container>
);
}
@@ -0,0 +1,60 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
export const Route = createFileRoute(
"/_authed/tournaments/$id/predictions_/make"
)({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
const myPrediction = await ensureServerQueryData(
queryClient,
predictionQueries.mine(params.id)
);
if (!myPrediction.eligible || myPrediction.locked) {
throw redirect({
to: "/tournaments/$id/predictions",
params: { id: params.id },
});
}
return {
tournament,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
},
}),
component: RouteComponent,
pendingComponent: BracketPending,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const { data: myPrediction } = useMyPrediction(id);
return (
<Container size="md" px={0}>
<PredictionEditor
tournament={tournament}
initialPicks={myPrediction.prediction?.picks ?? {}}
/>
</Container>
);
}