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
+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);
})
);