predictions

This commit is contained in:
2026-07-14 14:15:34 -07:00
parent e6c72a5789
commit 1783f0e6bc
28 changed files with 2085 additions and 89 deletions
@@ -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,
};
};