= ({
) : (
-
+ {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 9de9b25..bf4d45a 100644
--- a/src/features/tournaments/components/started-tournament/index.tsx
+++ b/src/features/tournaments/components/started-tournament/index.tsx
@@ -6,6 +6,10 @@ import { Carousel } from "@mantine/carousel";
import carouselClasses from "./carousel.module.css";
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";
@@ -42,6 +46,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 (
@@ -117,6 +137,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()}