Commissioners can add Brackt to any league. Each team drafts one manager from their own league; at season end, that manager's final overall fantasy ranking earns placement points. Resolution fires automatically when all other sports finalize. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
01e2f04656
commit
5b03b22267
31 changed files with 6796 additions and 154 deletions
|
|
@ -41,6 +41,7 @@ interface UseDraftSocketEventsParams {
|
|||
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||
setRoomClosed: (value: boolean) => void;
|
||||
setIsSyncing: (value: boolean) => void;
|
||||
setBracktVorps: (fn: (prev: Map<string, number>) => Map<string, number>) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocketEvents({
|
||||
|
|
@ -70,6 +71,7 @@ export function useDraftSocketEvents({
|
|||
setWatchedParticipantIds,
|
||||
setRoomClosed,
|
||||
setIsSyncing,
|
||||
setBracktVorps,
|
||||
}: UseDraftSocketEventsParams) {
|
||||
useEffect(() => {
|
||||
type PickMadePayload = {
|
||||
|
|
@ -238,6 +240,18 @@ export function useDraftSocketEvents({
|
|||
setRoomClosed(true);
|
||||
};
|
||||
|
||||
const handleBracktEvsUpdated = (data: {
|
||||
updates: Array<{ participantId: string; vorpValue: number }>;
|
||||
}) => {
|
||||
setBracktVorps((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const { participantId, vorpValue } of data.updates) {
|
||||
next.set(participantId, vorpValue);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
on("pick-made", handlePickMade as (data: unknown) => void);
|
||||
on("timer-update", handleTimerUpdate as (data: unknown) => void);
|
||||
on("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||
|
|
@ -255,6 +269,7 @@ export function useDraftSocketEvents({
|
|||
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
||||
on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
|
||||
on("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
off("pick-made", handlePickMade as (data: unknown) => void);
|
||||
|
|
@ -274,6 +289,7 @@ export function useDraftSocketEvents({
|
|||
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||
off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
||||
off("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
|
||||
off("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on, off, socketVersion]);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const AUDIT_ACTION_LABELS: Record<string, string> = {
|
|||
force_manual_pick: "Forced Manual Pick",
|
||||
draft_pick_changed: "Pick Replaced",
|
||||
time_bank_edited: "Time Bank Edited",
|
||||
brackt_resolved: "Brackt Resolved",
|
||||
};
|
||||
|
||||
const SCORING_FIELD_LABELS: Record<string, string> = {
|
||||
|
|
@ -201,6 +202,9 @@ export function formatAuditDetail(entry: AuditLogEntry): string {
|
|||
return parts.length > 0 ? parts.join(" | ") : "Sports updated";
|
||||
}
|
||||
|
||||
case "brackt_resolved":
|
||||
return d.trigger === "auto" ? "Brackt resolved automatically" : "Brackt resolution retried";
|
||||
|
||||
default:
|
||||
return (entry.action as string).replace(/_/g, " ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData {
|
|||
const fd = new FormData();
|
||||
fd.append("name", s.leagueName);
|
||||
fd.append("teamCount", s.teamCount.toString());
|
||||
if (s.selectedTemplate && s.selectedTemplate !== "customize") {
|
||||
fd.append("templateId", s.selectedTemplate);
|
||||
}
|
||||
fd.append("draftRounds", s.draftRounds.toString());
|
||||
if (s.draftDate && s.draftTime) {
|
||||
fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { getParticipantsForSeasonWithSports } from "./season-participant";
|
|||
import { getSeasonSportsSimple } from "./season-sport";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
/**
|
||||
* Check if the next team has autodraft enabled and immediately execute their pick
|
||||
|
|
@ -811,6 +812,17 @@ export async function executeAutoPick(params: {
|
|||
logger.error("[AutoPick] Socket.IO events error:", error);
|
||||
}
|
||||
|
||||
// Recompute Brackt EV/VORP after this pick is committed so autopick paths
|
||||
// (force-autopick, timer, user autodraft) are not one pick behind.
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error);
|
||||
}
|
||||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
// Only run the chain from the top-level call to prevent recursion
|
||||
if (!isDraftComplete && params.chainEnabled !== false) {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,13 @@ export interface UpdateProbabilityInput {
|
|||
*
|
||||
* Call this after any operation that changes EVs for seasonParticipants in the season.
|
||||
*/
|
||||
export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> {
|
||||
const db = database();
|
||||
export async function syncVorpForSeason(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId, db);
|
||||
if (allEvs.length === 0) return;
|
||||
|
||||
const sorted = [...allEvs].toSorted(
|
||||
|
|
@ -239,9 +242,10 @@ export async function getParticipantEV(
|
|||
* Get all participant EVs for a sports season
|
||||
*/
|
||||
export async function getAllParticipantEVsForSeason(
|
||||
sportsSeasonId: string
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<ParticipantEV[]> {
|
||||
const db = database();
|
||||
const db = providedDb || database();
|
||||
return db
|
||||
.select()
|
||||
.from(seasonParticipantExpectedValues)
|
||||
|
|
@ -280,9 +284,10 @@ export async function deleteParticipantEV(
|
|||
* All records succeed or all fail together.
|
||||
*/
|
||||
export async function batchUpsertParticipantEVs(
|
||||
inputs: CreateProbabilityInput[]
|
||||
inputs: CreateProbabilityInput[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<ParticipantEV[]> {
|
||||
const db = database();
|
||||
const db = providedDb || database();
|
||||
const results: ParticipantEV[] = [];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
|
@ -355,7 +360,7 @@ export async function batchUpsertParticipantEVs(
|
|||
|
||||
// Sync VORP for all affected seasons
|
||||
const uniqueSeasonIds = [...new Set(inputs.map((i) => i.sportsSeasonId))];
|
||||
await Promise.all(uniqueSeasonIds.map((id) => syncVorpForSeason(id)));
|
||||
await Promise.all(uniqueSeasonIds.map((id) => syncVorpForSeason(id, db)));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -775,6 +775,7 @@ export async function finalizeQualifyingPoints(
|
|||
.update(schema.sportsSeasons)
|
||||
.set({
|
||||
qualifyingPointsFinalized: true,
|
||||
status: "completed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
||||
|
|
@ -816,10 +817,11 @@ export async function processSeasonStandings(
|
|||
|
||||
// Group participants by position to handle ties
|
||||
const positionGroups: Record<number, string[]> = {};
|
||||
const processedParticipantIds = new Set<string>();
|
||||
|
||||
for (const result of seasonResults) {
|
||||
const position = result.currentPosition;
|
||||
if (position === null) continue; // Skip participants without a position
|
||||
if (position === null) continue; // handled after the loop
|
||||
|
||||
if (!positionGroups[position]) {
|
||||
positionGroups[position] = [];
|
||||
|
|
@ -838,8 +840,14 @@ export async function processSeasonStandings(
|
|||
for (const position of positions) {
|
||||
const participantIds = positionGroups[position];
|
||||
|
||||
// Stop if we've gone beyond the top 8 fantasy placements
|
||||
if (currentPlacement > 8) break;
|
||||
if (currentPlacement > 8) {
|
||||
// Beyond top 8 — assign 0 to all remaining positioned participants
|
||||
for (const participantId of participantIds) {
|
||||
processedParticipantIds.add(participantId);
|
||||
await upsertParticipantResult(participantId, sportsSeasonId, 0, db);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how many placements this group shares
|
||||
const participantsInGroup = participantIds.length;
|
||||
|
|
@ -848,47 +856,33 @@ export async function processSeasonStandings(
|
|||
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
|
||||
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
|
||||
|
||||
// Determine if we should assign placements to this group
|
||||
if (currentPlacement <= 8) {
|
||||
// All participants tied at this position get the first placement in the range
|
||||
// (e.g., if 4 people tie for 5th place, they all get placement 5)
|
||||
// The scoring system will handle averaging the points for positions 5, 6, 7, 8
|
||||
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
|
||||
const participantId = participantIds[i];
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
currentPlacement,
|
||||
db
|
||||
);
|
||||
processedParticipantIds.add(participantId);
|
||||
await upsertParticipantResult(participantId, sportsSeasonId, currentPlacement, db);
|
||||
}
|
||||
|
||||
// If there are more participants than scoring positions, assign 0 to the rest
|
||||
for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
|
||||
const participantId = participantIds[i];
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
0, // Beyond top 8
|
||||
db
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Position is beyond top 8, assign 0 points
|
||||
for (const participantId of participantIds) {
|
||||
await upsertParticipantResult(
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
0,
|
||||
db
|
||||
);
|
||||
}
|
||||
processedParticipantIds.add(participantId);
|
||||
await upsertParticipantResult(participantId, sportsSeasonId, 0, db);
|
||||
}
|
||||
|
||||
// Move to next placement group
|
||||
currentPlacement += participantsInGroup;
|
||||
}
|
||||
|
||||
// Assign 0 to participants with no championship position recorded
|
||||
for (const result of seasonResults) {
|
||||
if (!processedParticipantIds.has(result.participantId)) {
|
||||
await upsertParticipantResult(result.participantId, sportsSeasonId, 0, db);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger recalculation for all affected leagues
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { eq, inArray, sql, lte, gte } from "drizzle-orm";
|
||||
import { eq, inArray, isNull, sql, lte, gte } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
|
||||
export async function countSportsSeasons(): Promise<number> {
|
||||
const db = database();
|
||||
|
|
@ -122,6 +123,12 @@ export async function findAllSportsSeasons(): Promise<SportsSeasonListItem[]> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function findAllAdminSportsSeasons(): Promise<SportsSeasonListItem[]> {
|
||||
const seasons = await findAllSportsSeasons();
|
||||
// Hide per-league private copies (fantasySeasonId IS NOT NULL); show the global template
|
||||
return seasons.filter((season) => season.fantasySeasonId === null);
|
||||
}
|
||||
|
||||
export async function findDraftableSportsSeasons() {
|
||||
const db = database();
|
||||
const today = sql`CURRENT_DATE`;
|
||||
|
|
@ -129,7 +136,8 @@ export async function findDraftableSportsSeasons() {
|
|||
where: (ss, { and }) =>
|
||||
and(
|
||||
lte(ss.draftOn, today),
|
||||
gte(ss.draftOff, today)
|
||||
gte(ss.draftOff, today),
|
||||
isNull(ss.fantasySeasonId)
|
||||
),
|
||||
orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)],
|
||||
with: {
|
||||
|
|
@ -148,11 +156,20 @@ export async function updateSportsSeason(
|
|||
data: Partial<NewSportsSeason>
|
||||
): Promise<SportsSeason> {
|
||||
const db = database();
|
||||
const previous = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, id),
|
||||
columns: { status: true },
|
||||
});
|
||||
const [sportsSeason] = await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.sportsSeasons.id, id))
|
||||
.returning();
|
||||
|
||||
if (previous && previous.status !== "completed" && sportsSeason.status === "completed") {
|
||||
await maybeResolveCompletedBracktForSportsSeason(sportsSeason.id, db);
|
||||
}
|
||||
|
||||
return sportsSeason;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link } from "react-router";
|
||||
import { Form, Link } from "react-router";
|
||||
import { useState } from "react";
|
||||
import type { Route } from "./+types/admin._index";
|
||||
|
||||
|
|
@ -17,6 +17,16 @@ import {
|
|||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink, Users } from "lucide-react";
|
||||
import { seedOrRepairBracktTemplate } from "~/services/brackt.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
if (formData.get("intent") === "seed-brackt") {
|
||||
await seedOrRepairBracktTemplate();
|
||||
return { success: true, message: "Brackt sport and template repaired." };
|
||||
}
|
||||
return { error: "Invalid action" };
|
||||
}
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Admin - Brackt" }];
|
||||
|
|
@ -250,7 +260,7 @@ function GamesToScore({
|
|||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
||||
export default function AdminDashboard({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { stats, seasonStatusCounts, upcomingEvents, today, tomorrow } = loaderData;
|
||||
|
||||
// Serialize dates to strings for the client component
|
||||
|
|
@ -374,6 +384,16 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
|||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="seed-brackt" />
|
||||
<Button variant="outline" className="w-full justify-between" type="submit">
|
||||
Seed/Repair Brackt
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Form>
|
||||
{actionData && "message" in actionData && (
|
||||
<p className="text-sm text-muted-foreground">{actionData.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { logger } from "~/lib/logger";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -723,6 +724,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ status: "completed", updatedAt: new Date() })
|
||||
.where(eq(schema.sportsSeasons.id, params.id));
|
||||
|
||||
// Recalculate standings for all affected fantasy seasons
|
||||
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
|
||||
|
||||
|
|
@ -730,6 +736,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
await recalculateStandings(seasonSport.seasonId, db);
|
||||
await createDailySnapshot(seasonSport.seasonId, db);
|
||||
}
|
||||
await maybeResolveCompletedBracktForSportsSeason(params.id, db);
|
||||
|
||||
return {
|
||||
success: `Bracket finalized! All placements calculated and standings updated.`,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "~/models/scoring-event";
|
||||
import { getQPStandings } from "~/models/qualifying-points";
|
||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { upsertTournament, findTournamentsBySport, getTournamentById } from "~/models/tournament";
|
||||
import { extractTournamentIdentity } from "~/lib/tournament-identity";
|
||||
|
||||
|
|
@ -87,6 +88,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
if (intent === "finalize-qp") {
|
||||
try {
|
||||
await finalizeQualifyingPoints(params.id);
|
||||
await maybeResolveCompletedBracktForSportsSeason(params.id);
|
||||
return { success: "Qualifying points finalized and fantasy placements assigned to top 8!" };
|
||||
} catch (error) {
|
||||
logger.error("Error finalizing qualifying points:", error);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons";
|
||||
|
||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||
import { findAllAdminSportsSeasons } from "~/models/sports-season";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -26,7 +26,7 @@ export function meta(): Route.MetaDescriptors {
|
|||
}
|
||||
|
||||
export async function loader() {
|
||||
const sportsSeasons = await findAllSportsSeasons();
|
||||
const sportsSeasons = await findAllAdminSportsSeasons();
|
||||
return { sportsSeasons };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-
|
|||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
|
|
@ -280,6 +281,15 @@ export async function action(args: ActionFunctionArgs) {
|
|||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
if (!isDraftComplete) {
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Brackt EV update after forced manual pick failed:", error);
|
||||
}
|
||||
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
await checkAndTriggerNextAutodraft({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "~/models/season-sport";
|
|||
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
|
|
@ -302,6 +303,15 @@ export async function action(args: ActionFunctionArgs) {
|
|||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
if (!isDraftComplete) {
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Brackt EV update after pick failed:", error);
|
||||
}
|
||||
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
await checkAndTriggerNextAutodraft({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner";
|
|||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
|
|
@ -97,11 +98,8 @@ export async function action(args: ActionFunctionArgs) {
|
|||
},
|
||||
});
|
||||
|
||||
// Emit socket events
|
||||
try {
|
||||
const io = getSocketIO();
|
||||
|
||||
io.to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
||||
seasonId,
|
||||
pickNumber,
|
||||
teamId: rollbackSlot?.teamId,
|
||||
|
|
@ -110,5 +108,14 @@ export async function action(args: ActionFunctionArgs) {
|
|||
logger.error("Socket.IO error:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Rollback] Brackt EV update failed:", error);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, pickNumber });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,30 @@ export default function HowToPlay() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* The Meta Game */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-3">The Meta Game: Brackt</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Some leagues include <span className="font-semibold text-foreground">Brackt</span> as
|
||||
an optional meta-sport. Instead of drafting an athlete or team, you draft{" "}
|
||||
<span className="font-semibold text-foreground">another manager from your own league</span>.
|
||||
At the end of the season, their final overall fantasy ranking — 1st through 8th across
|
||||
all the other sports — earns you the standard placement points.
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg p-4 text-sm text-muted-foreground space-y-2 mb-4">
|
||||
<p className="font-semibold text-foreground">Strategic angles:</p>
|
||||
<ul className="space-y-1 pl-3 list-disc">
|
||||
<li>Draft timing matters — managers who pick strong early show higher projected value.</li>
|
||||
<li>Drafting yourself is allowed and is a valid strategy.</li>
|
||||
<li>Brackt scores are revealed at season end, after all other sports finalize.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Brackt is resolved automatically once every other sport concludes. It's the last piece
|
||||
of the puzzle — and sometimes the most dramatic.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* During the Season */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-3">After the Draft</h2>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export default function DraftRoom() {
|
|||
season,
|
||||
draftSlots,
|
||||
draftPicks,
|
||||
availableParticipants,
|
||||
availableParticipants: initialAvailableParticipants,
|
||||
userTeam,
|
||||
userQueue,
|
||||
userWatchlist,
|
||||
|
|
@ -191,6 +191,17 @@ export default function DraftRoom() {
|
|||
ownerMap,
|
||||
teamTimezoneMap,
|
||||
} = useLoaderData<typeof loader>();
|
||||
const availableParticipants = initialAvailableParticipants;
|
||||
const [bracktVorps, setBracktVorps] = useState<Map<string, number>>(() => new Map());
|
||||
const sortedAvailableParticipants = useMemo(() => {
|
||||
if (bracktVorps.size === 0) return availableParticipants;
|
||||
return availableParticipants.toSorted((a, b) => {
|
||||
const aVorp = bracktVorps.get(a.id) ?? parseFloat(a.vorpValue ?? "0");
|
||||
const bVorp = bracktVorps.get(b.id) ?? parseFloat(b.vorpValue ?? "0");
|
||||
const diff = bVorp - aVorp;
|
||||
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [availableParticipants, bracktVorps]);
|
||||
const navigate = useNavigate();
|
||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||
|
|
@ -383,13 +394,13 @@ export default function DraftRoom() {
|
|||
const participantRanks = useMemo(() => {
|
||||
const ranks = new Map<string, { overallRank: number; sportRank: number }>();
|
||||
const sportCounters = new Map<string, number>();
|
||||
availableParticipants.forEach((p, i) => {
|
||||
sortedAvailableParticipants.forEach((p, i) => {
|
||||
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
|
||||
sportCounters.set(p.sport.id, sportIdx);
|
||||
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
|
||||
});
|
||||
return ranks;
|
||||
}, [availableParticipants]);
|
||||
}, [sortedAvailableParticipants]);
|
||||
|
||||
// Calculate draft eligibility for current user's team
|
||||
const eligibility = useMemo(() => {
|
||||
|
|
@ -472,6 +483,7 @@ export default function DraftRoom() {
|
|||
setWatchedParticipantIds,
|
||||
setRoomClosed,
|
||||
setIsSyncing,
|
||||
setBracktVorps,
|
||||
});
|
||||
|
||||
// Persist sidebar collapsed state
|
||||
|
|
@ -1063,7 +1075,7 @@ export default function DraftRoom() {
|
|||
// Filter participants based on search, sport, drafted status, and eligibility
|
||||
const filteredParticipants = useMemo(() => {
|
||||
const sportFilterSet = new Set(sportFilters);
|
||||
return availableParticipants.filter((participant) => {
|
||||
return sortedAvailableParticipants.filter((participant) => {
|
||||
// Drafted filter - hide drafted participants by default
|
||||
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
|
||||
return false;
|
||||
|
|
@ -1096,7 +1108,7 @@ export default function DraftRoom() {
|
|||
}
|
||||
return true;
|
||||
});
|
||||
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
|
||||
}, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
|
||||
|
||||
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
|
||||
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
|
||||
|
|
@ -1203,7 +1215,7 @@ export default function DraftRoom() {
|
|||
const queueSectionProps = userTeam
|
||||
? {
|
||||
queue,
|
||||
availableParticipants,
|
||||
availableParticipants: sortedAvailableParticipants,
|
||||
canPick,
|
||||
onRemoveFromQueue: handleRemoveFromQueue,
|
||||
onReorder: handleReorderQueue,
|
||||
|
|
@ -1601,7 +1613,7 @@ export default function DraftRoom() {
|
|||
}}
|
||||
title="Force Manual Pick"
|
||||
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
|
||||
participants={availableParticipants}
|
||||
participants={sortedAvailableParticipants}
|
||||
draftedParticipantIds={draftedParticipantIds}
|
||||
eligibility={forcePickEligibility}
|
||||
uniqueSports={uniqueSports}
|
||||
|
|
@ -1616,7 +1628,7 @@ export default function DraftRoom() {
|
|||
}}
|
||||
title="Replace Pick"
|
||||
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
|
||||
participants={availableParticipants}
|
||||
participants={sortedAvailableParticipants}
|
||||
draftedParticipantIds={draftedParticipantIds}
|
||||
allowParticipantId={replacePickSlot?.oldParticipantId}
|
||||
currentParticipantId={replacePickSlot?.oldParticipantId}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
|
|||
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
|
||||
import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
applyBracktSportsForSeason,
|
||||
seedOrRepairBracktTemplate,
|
||||
syncPrivateBracktParticipants,
|
||||
} from "~/services/brackt.server";
|
||||
|
||||
function isValidHHMM(s: string): boolean {
|
||||
return /^\d{2}:\d{2}$/.test(s);
|
||||
|
|
@ -81,6 +86,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
|
||||
}
|
||||
|
||||
const BRACKT_SLUG = "brackt";
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
|
@ -108,6 +115,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
await seedOrRepairBracktTemplate();
|
||||
|
||||
// Get current season with sports and draftable seasons in parallel
|
||||
const [season, draftableSportsSeasons] = await Promise.all([
|
||||
findCurrentSeasonWithSports(leagueId),
|
||||
|
|
@ -120,7 +129,10 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
||||
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
||||
.map((s) => s.sportsSeason)
|
||||
.filter((ss) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today));
|
||||
.filter((ss) => {
|
||||
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||
return !draftableIds.has(ss.id) && (fantasySeasonId || ss.draftOff < today || ss.draftOn > today);
|
||||
});
|
||||
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
||||
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
||||
|
||||
|
|
@ -173,7 +185,10 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
teams,
|
||||
teamCount: teams.length,
|
||||
teamsWithOwners,
|
||||
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
||||
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & {
|
||||
fantasySeasonId: string | null;
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
}>,
|
||||
draftSlots,
|
||||
isAdmin,
|
||||
allUsers,
|
||||
|
|
@ -672,11 +687,12 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
// Handle sports changes (only valid in pre_draft)
|
||||
if (season.status === "pre_draft") {
|
||||
const selectedSports = formData.getAll("sportsSeasons");
|
||||
const selectedSports = formData.getAll("sportsSeasons").map(String);
|
||||
const currentSportIds = new Set(
|
||||
season.seasonSports?.map((s) => s.sportsSeason.id) || []
|
||||
);
|
||||
const newSportIds = new Set(selectedSports as string[]);
|
||||
const sportsToApply = await applyBracktSportsForSeason(season.id, selectedSports);
|
||||
const newSportIds = new Set(sportsToApply);
|
||||
|
||||
for (const sportId of currentSportIds) {
|
||||
if (!newSportIds.has(sportId)) {
|
||||
|
|
@ -689,7 +705,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
if (!currentSportIds.has(sportId)) {
|
||||
sportsToAdd.push({
|
||||
seasonId: season.id,
|
||||
sportsSeasonId: sportId as string,
|
||||
sportsSeasonId: sportId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -769,7 +785,11 @@ export async function action(args: Route.ActionArgs) {
|
|||
}))
|
||||
);
|
||||
});
|
||||
await syncPrivateBracktParticipants(season.id);
|
||||
} else if (newTeamCount < currentTeamCount) {
|
||||
if (season.status !== "pre_draft") {
|
||||
return { error: "Teams cannot be removed after the draft has started" };
|
||||
}
|
||||
// Remove teams without owners (from the end)
|
||||
const teamsToRemove = teams
|
||||
.filter(t => t.ownerId === null)
|
||||
|
|
@ -778,6 +798,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
for (const team of teamsToRemove) {
|
||||
await deleteTeam(team.id);
|
||||
}
|
||||
await syncPrivateBracktParticipants(season.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -886,7 +907,24 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
return "standard";
|
||||
});
|
||||
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
||||
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
|
||||
() => {
|
||||
const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? [];
|
||||
const linkedIds = linkedSeasons.map((ss) => ss.id);
|
||||
const templateBrackt = allSportsSeasons.find((ss) => {
|
||||
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||
return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId;
|
||||
});
|
||||
const privateBrackt = linkedSeasons.find((ss) => {
|
||||
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||
return ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId;
|
||||
});
|
||||
|
||||
if (templateBrackt && privateBrackt) {
|
||||
return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id)));
|
||||
}
|
||||
|
||||
return new Set(linkedIds);
|
||||
}
|
||||
);
|
||||
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
||||
draftSlots.length > 0
|
||||
|
|
@ -937,6 +975,18 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
// The form submission will handle the actual deletion
|
||||
};
|
||||
|
||||
const displaySportsSeasons = (() => {
|
||||
const templateBrackt = allSportsSeasons.find((ss) => {
|
||||
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||
return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId;
|
||||
});
|
||||
if (!templateBrackt) return allSportsSeasons;
|
||||
return allSportsSeasons.filter((ss) => {
|
||||
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||
return !(ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId);
|
||||
});
|
||||
})();
|
||||
|
||||
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
|
||||
const newOrder = [...draftOrderTeams];
|
||||
const [movedTeam] = newOrder.splice(fromIndex, 1);
|
||||
|
|
@ -1311,8 +1361,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
Sports Seasons ({selectedSports.size} selected)
|
||||
</Label>
|
||||
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
|
||||
{allSportsSeasons.length > 0 ? (
|
||||
allSportsSeasons.map((ss) => (
|
||||
{displaySportsSeasons.length > 0 ? (
|
||||
displaySportsSeasons.map((ss) => (
|
||||
<div key={ss.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={ss.id}
|
||||
|
|
@ -1326,7 +1376,13 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
htmlFor={ss.id}
|
||||
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
|
||||
>
|
||||
{ss.sport.slug === BRACKT_SLUG ? (
|
||||
<span className="font-medium">Brackt</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year})
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{ss.scoringType.replace("_", " ")}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { findDraftableSportsSeasons } from "~/models/sports-season";
|
|||
import { findUserById, updateUser } from "~/models/user";
|
||||
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarClock, Check, Star, Swords, Trophy } from "lucide-react";
|
||||
import { CalendarClock, Check, Info, Star, Swords, Trophy } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
|
@ -36,6 +36,10 @@ import { WizardStepper } from "~/components/league/WizardStepper";
|
|||
import { ReviewSection } from "~/components/league/ReviewSection";
|
||||
import { StepperInput } from "~/components/league/StepperInput";
|
||||
import { WizardAuthForm } from "~/components/league/WizardAuthForm";
|
||||
import {
|
||||
applyBracktSportsForSeason,
|
||||
seedOrRepairBracktTemplate,
|
||||
} from "~/services/brackt.server";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -44,6 +48,7 @@ type SportSeason = {
|
|||
name: string;
|
||||
year: number;
|
||||
scoringType: string;
|
||||
fantasySeasonId: string | null;
|
||||
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
|
||||
participantCount: number;
|
||||
};
|
||||
|
|
@ -56,12 +61,24 @@ type Template = {
|
|||
sportsSeasonIds: string[];
|
||||
};
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
const BRACKT_SPORT_TOOLTIP =
|
||||
"Draft managers from your own league. They score based on how their teams perform across the other sports.";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Basketball", "Football", "Baseball", "Hockey",
|
||||
"Soccer", "Motorsport", "Golf", "Tennis", "Other",
|
||||
];
|
||||
function isBracktSport(sport: { slug: string }) {
|
||||
return sport.slug === "brackt";
|
||||
}
|
||||
|
||||
function BracktInfoIcon() {
|
||||
return (
|
||||
<span
|
||||
title={BRACKT_SPORT_TOOLTIP}
|
||||
aria-label={BRACKT_SPORT_TOOLTIP}
|
||||
className="inline-flex text-muted-foreground"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRounds(count: number): number {
|
||||
if (count <= 0) return 3;
|
||||
|
|
@ -69,22 +86,6 @@ function defaultRounds(count: number): number {
|
|||
return Math.max(count + 3, Math.ceil(count * pct));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function getSportCategory(sportName: string): string {
|
||||
const n = sportName.toLowerCase();
|
||||
if (n.includes("basketball") || n === "nba" || n === "wnba" || n === "euroleague" || n.includes("ncaa")) return "Basketball";
|
||||
if (n === "nfl" || n === "cfl" || n === "xfl" || (n.includes("football") && !n.includes("soccer"))) return "Football";
|
||||
if (n === "mlb" || n.includes("baseball") || n === "kbo" || n === "npb" || n.includes("little league")) return "Baseball";
|
||||
if (n === "nhl" || n === "ahl" || n === "pwhl" || n.includes("hockey") || n.includes("iihf")) return "Hockey";
|
||||
if (n.includes("soccer") || n.includes("mls") || n.includes("premier") || n.includes("champions")) return "Soccer";
|
||||
if (n === "f1" || n.includes("formula") || n.includes("indy") || n.includes("nascar") || n.includes("racing")) return "Motorsport";
|
||||
if (n.includes("golf") || n.includes("pga")) return "Golf";
|
||||
if (n.includes("tennis") || n.includes("atp") || n.includes("wta")) return "Tennis";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// ─── Meta ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
|
|
@ -97,6 +98,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
||||
await seedOrRepairBracktTemplate();
|
||||
|
||||
const templates = await findActiveSeasonTemplates();
|
||||
const allSportsSeasons = await findDraftableSportsSeasons();
|
||||
|
||||
|
|
@ -213,7 +216,9 @@ export async function action(args: Route.ActionArgs) {
|
|||
leagueId: league.id,
|
||||
year: currentYear,
|
||||
status: "pre_draft",
|
||||
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
|
||||
templateId: typeof templateId === "string" && templateId !== "" && templateId !== "customize"
|
||||
? templateId
|
||||
: null,
|
||||
draftRounds: draftRoundsNum,
|
||||
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
||||
draftInitialTime: draftInitialTimeNum,
|
||||
|
|
@ -228,7 +233,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
await setCurrentSeason(league.id, season.id);
|
||||
|
||||
const selectedSports = formData.getAll("sportsSeasons");
|
||||
const selectedSports = formData.getAll("sportsSeasons").map(String);
|
||||
|
||||
if (selectedSports.length > draftRoundsNum) {
|
||||
return {
|
||||
|
|
@ -236,15 +241,6 @@ export async function action(args: Route.ActionArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
if (selectedSports.length > 0) {
|
||||
await linkMultipleSportsToSeason(
|
||||
selectedSports.map((id) => ({
|
||||
seasonId: season.id,
|
||||
sportsSeasonId: id as string,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
|
||||
const teamNames = generateUniqueTeamNames(teamCountNum);
|
||||
const creator = await findUserById(userId);
|
||||
|
|
@ -259,6 +255,16 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
await createManyTeams(teams);
|
||||
|
||||
const sportsToLink = await applyBracktSportsForSeason(season.id, selectedSports);
|
||||
if (sportsToLink.length > 0) {
|
||||
await linkMultipleSportsToSeason(
|
||||
sportsToLink.map((id) => ({
|
||||
seasonId: season.id,
|
||||
sportsSeasonId: id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const userTimezone = formData.get("userTimezone");
|
||||
if (typeof userTimezone === "string" && userTimezone) {
|
||||
await updateUser(userId, { timezone: userTimezone });
|
||||
|
|
@ -401,20 +407,18 @@ function Step2Sports({
|
|||
// When a named template is selected, only show that template's seasons
|
||||
// Always exclude seasons with fewer participants than the league's team count
|
||||
const activeTemplate = templates.find((t) => t.id === selectedTemplate);
|
||||
const eligibleSeasons = allSportsSeasons.filter((s) => s.participantCount >= teamCount);
|
||||
const eligibleSeasons = allSportsSeasons.filter(
|
||||
(s) => s.participantCount >= teamCount || (s.sport.slug === "brackt" && s.fantasySeasonId === null)
|
||||
);
|
||||
const displaySeasons = selectedTemplate === "customize" || !activeTemplate
|
||||
? eligibleSeasons
|
||||
: eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id));
|
||||
|
||||
// Group sports by category
|
||||
const grouped: { category: string; seasons: SportSeason[] }[] = CATEGORY_ORDER
|
||||
.map((cat) => ({
|
||||
category: cat,
|
||||
seasons: displaySeasons.filter((s) => getSportCategory(s.sport.name) === cat),
|
||||
}))
|
||||
.filter(({ seasons }) =>
|
||||
seasons.length > 0
|
||||
);
|
||||
const sortedDisplaySeasons = displaySeasons.toSorted((a, b) => {
|
||||
const sportNameDiff = a.sport.name.localeCompare(b.sport.name);
|
||||
if (sportNameDiff !== 0) return sportNameDiff;
|
||||
return b.year - a.year;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -486,9 +490,14 @@ function Step2Sports({
|
|||
return acc;
|
||||
}, {})
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name)).map((sport) => (
|
||||
<li key={sport.name} className="flex items-center gap-2 text-sm">
|
||||
<li
|
||||
key={sport.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
title={isBracktSport(sport) ? BRACKT_SPORT_TOOLTIP : undefined}
|
||||
>
|
||||
<SportIcon sport={sport} />
|
||||
<span>{sport.name}</span>
|
||||
{isBracktSport(sport) && <BracktInfoIcon />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -540,21 +549,16 @@ function Step2Sports({
|
|||
)}
|
||||
|
||||
{/* Full sport picker */}
|
||||
<div className="relative">
|
||||
<div className="space-y-4 max-h-72 overflow-y-auto pr-1">
|
||||
{grouped.map(({ category, seasons }) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{category}
|
||||
</p>
|
||||
<div className="max-h-72 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{seasons.map((s) => {
|
||||
{sortedDisplaySeasons.map((s) => {
|
||||
const selected = selectedSports.has(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => toggleSport(s.id)}
|
||||
title={isBracktSport(s.sport) ? BRACKT_SPORT_TOOLTIP : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm border transition-colors",
|
||||
selected
|
||||
|
|
@ -565,19 +569,16 @@ function Step2Sports({
|
|||
<span className="flex items-center gap-2">
|
||||
<SportIcon sport={s.sport} />
|
||||
{s.sport.name} <span className="text-xs opacity-70">{s.year}</span>
|
||||
{isBracktSport(s.sport) && <BracktInfoIcon />}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{grouped.length === 0 && (
|
||||
{sortedDisplaySeasons.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No sports seasons available.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,32 @@ export default function Rules() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Brackt */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-1">Brackt — Draft a Manager</h2>
|
||||
<div className="border-b mb-4" />
|
||||
<div className="space-y-3 text-muted-foreground">
|
||||
<p>
|
||||
Commissioners may add <span className="font-semibold text-foreground">Brackt</span> as
|
||||
an optional meta-sport. When included, each team drafts exactly one manager from
|
||||
their own league — including themselves.
|
||||
</p>
|
||||
<p>
|
||||
At season end, the drafted manager's final overall fantasy ranking across all other
|
||||
sports determines how many points their drafter earns, using the same 1st–8th
|
||||
placement point values as every other sport.
|
||||
</p>
|
||||
<p>
|
||||
Brackt adds one required draft round to every league that includes it. Self-drafting
|
||||
is explicitly allowed and is a valid strategy.
|
||||
</p>
|
||||
<p>
|
||||
Brackt is resolved automatically once every other sport in the league has been
|
||||
finalized.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The Draft */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-1">The Draft</h2>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { syncPrivateBracktParticipants } from "~/services/brackt.server";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Team Settings — ${data?.team?.name ?? "Team"} - Brackt` }];
|
||||
|
|
@ -100,6 +101,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
name: name.trim(),
|
||||
logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined,
|
||||
});
|
||||
await syncPrivateBracktParticipants(team.seasonId);
|
||||
|
||||
const season = await findSeasonById(team.seasonId);
|
||||
return redirect(`/leagues/${season?.leagueId}?updated=true`);
|
||||
|
|
|
|||
350
app/services/brackt.server.ts
Normal file
350
app/services/brackt.server.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { batchUpsertParticipantEVs, syncVorpForSeason } from "~/models/participant-expected-value";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { BracktSimulator } from "~/services/simulations/brackt-simulator";
|
||||
|
||||
export const BRACKT_SLUG = "brackt";
|
||||
|
||||
type Db = ReturnType<typeof database>;
|
||||
|
||||
function isBracktSport(sport: { slug: string } | null | undefined) {
|
||||
return sport?.slug === BRACKT_SLUG;
|
||||
}
|
||||
|
||||
export async function seedOrRepairBracktTemplate(providedDb?: Db) {
|
||||
const db = providedDb || database();
|
||||
return await db.transaction(async (tx) => {
|
||||
const existingSport = await tx.query.sports.findFirst({
|
||||
where: eq(schema.sports.slug, BRACKT_SLUG),
|
||||
});
|
||||
|
||||
const [sport] = existingSport
|
||||
? await tx
|
||||
.update(schema.sports)
|
||||
.set({ name: "Brackt", type: "team", simulatorType: "brackt", updatedAt: new Date() })
|
||||
.where(eq(schema.sports.id, existingSport.id))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(schema.sports)
|
||||
.values({
|
||||
name: "Brackt",
|
||||
type: "team",
|
||||
slug: BRACKT_SLUG,
|
||||
description: "League meta-sport based on final Brackt standings",
|
||||
simulatorType: "brackt",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const existingTemplate = await tx.query.sportsSeasons.findFirst({
|
||||
where: and(eq(schema.sportsSeasons.sportId, sport.id), isNull(schema.sportsSeasons.fantasySeasonId)),
|
||||
orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)],
|
||||
});
|
||||
|
||||
const templateValues = {
|
||||
sportId: sport.id,
|
||||
name: "Brackt",
|
||||
year: 2026,
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2099-12-31",
|
||||
status: "upcoming" as const,
|
||||
scoringType: "regular_season" as const,
|
||||
scoringPattern: "season_standings" as const,
|
||||
draftOn: "2026-01-01",
|
||||
draftOff: "2099-12-31",
|
||||
fantasySeasonId: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [template] = existingTemplate
|
||||
? await tx
|
||||
.update(schema.sportsSeasons)
|
||||
.set(templateValues)
|
||||
.where(eq(schema.sportsSeasons.id, existingTemplate.id))
|
||||
.returning()
|
||||
: await tx.insert(schema.sportsSeasons).values(templateValues).returning();
|
||||
|
||||
return { sport, template };
|
||||
});
|
||||
}
|
||||
|
||||
export async function findPrivateBracktSeason(fantasySeasonId: string, providedDb?: Db) {
|
||||
const db = providedDb || database();
|
||||
return db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.fantasySeasonId, fantasySeasonId),
|
||||
with: { sport: true },
|
||||
}).then((season) => (isBracktSport(season?.sport) ? season : undefined));
|
||||
}
|
||||
|
||||
async function ensurePrivateBracktSeason(fantasySeasonId: string, templateId: string, db: Db) {
|
||||
const existing = await findPrivateBracktSeason(fantasySeasonId, db);
|
||||
if (existing) return existing;
|
||||
|
||||
const template = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, templateId),
|
||||
with: { sport: true },
|
||||
});
|
||||
if (!template || !isBracktSport(template.sport)) {
|
||||
throw new Error("Brackt template sports season not found");
|
||||
}
|
||||
|
||||
const fantasySeason = await db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, fantasySeasonId),
|
||||
with: { league: true },
|
||||
});
|
||||
|
||||
const [privateSeason] = await db
|
||||
.insert(schema.sportsSeasons)
|
||||
.values({
|
||||
sportId: template.sportId,
|
||||
fantasySeasonId,
|
||||
name: `${fantasySeason?.league.name ?? "League"} Brackt`,
|
||||
year: fantasySeason?.year ?? template.year,
|
||||
startDate: template.startDate,
|
||||
endDate: template.endDate,
|
||||
status: "upcoming",
|
||||
scoringType: "regular_season",
|
||||
scoringPattern: "season_standings",
|
||||
draftOn: template.draftOn,
|
||||
draftOff: template.draftOff,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { ...privateSeason, sport: template.sport };
|
||||
}
|
||||
|
||||
export async function syncPrivateBracktParticipants(fantasySeasonId: string, providedDb?: Db) {
|
||||
const db = providedDb || database();
|
||||
const bracktSeason = await findPrivateBracktSeason(fantasySeasonId, db);
|
||||
if (!bracktSeason) return null;
|
||||
|
||||
const teams = await db.query.teams.findMany({
|
||||
where: eq(schema.teams.seasonId, fantasySeasonId),
|
||||
orderBy: (teamsTable, { asc }) => [asc(teamsTable.name)],
|
||||
});
|
||||
const participants = await db.query.seasonParticipants.findMany({
|
||||
where: eq(schema.seasonParticipants.sportsSeasonId, bracktSeason.id),
|
||||
});
|
||||
|
||||
const participantByTeamId = new Map(participants.map((p) => [p.externalId, p]));
|
||||
const teamIds = new Set(teams.map((team) => team.id));
|
||||
|
||||
for (const team of teams) {
|
||||
const participant = participantByTeamId.get(team.id);
|
||||
if (participant) {
|
||||
if (participant.name !== team.name || participant.shortName !== team.name) {
|
||||
await db
|
||||
.update(schema.seasonParticipants)
|
||||
.set({ name: team.name, shortName: team.name, updatedAt: new Date() })
|
||||
.where(eq(schema.seasonParticipants.id, participant.id));
|
||||
}
|
||||
} else {
|
||||
await db.insert(schema.seasonParticipants).values({
|
||||
sportsSeasonId: bracktSeason.id,
|
||||
name: team.name,
|
||||
shortName: team.name,
|
||||
externalId: team.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const obsolete = participants.filter((p) => p.externalId && !teamIds.has(p.externalId));
|
||||
if (obsolete.length > 0) {
|
||||
await db
|
||||
.delete(schema.seasonParticipants)
|
||||
.where(inArray(schema.seasonParticipants.id, obsolete.map((p) => p.id)));
|
||||
}
|
||||
|
||||
await runBracktHarvilleForFantasySeason(fantasySeasonId, db);
|
||||
return bracktSeason;
|
||||
}
|
||||
|
||||
export async function applyBracktSportsForSeason(
|
||||
fantasySeasonId: string,
|
||||
selectedSportsSeasonIds: string[],
|
||||
providedDb?: Db
|
||||
): Promise<string[]> {
|
||||
const db = providedDb || database();
|
||||
const selected = [...new Set(selectedSportsSeasonIds)];
|
||||
if (selected.length === 0) return selected;
|
||||
|
||||
const sportsSeasons = await db.query.sportsSeasons.findMany({
|
||||
where: inArray(schema.sportsSeasons.id, selected),
|
||||
with: { sport: true },
|
||||
});
|
||||
const bracktTemplate = sportsSeasons.find((ss) => isBracktSport(ss.sport) && ss.fantasySeasonId === null);
|
||||
const existingPrivate = await findPrivateBracktSeason(fantasySeasonId, db);
|
||||
const nonBracktIds = selected.filter((id) => {
|
||||
const ss = sportsSeasons.find((s) => s.id === id);
|
||||
return !ss || !isBracktSport(ss.sport);
|
||||
});
|
||||
|
||||
if (!bracktTemplate && existingPrivate && !selected.includes(existingPrivate.id)) {
|
||||
await db.delete(schema.sportsSeasons).where(eq(schema.sportsSeasons.id, existingPrivate.id));
|
||||
return nonBracktIds;
|
||||
}
|
||||
|
||||
if (!bracktTemplate) return selected;
|
||||
|
||||
const privateSeason = await ensurePrivateBracktSeason(fantasySeasonId, bracktTemplate.id, db);
|
||||
await syncPrivateBracktParticipants(fantasySeasonId, db);
|
||||
return [...nonBracktIds, privateSeason.id];
|
||||
}
|
||||
|
||||
export async function runBracktHarvilleForFantasySeason(fantasySeasonId: string, db: Db) {
|
||||
const bracktSeason = await findPrivateBracktSeason(fantasySeasonId, db);
|
||||
if (!bracktSeason) return [];
|
||||
|
||||
const fantasySeason = await db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, fantasySeasonId),
|
||||
});
|
||||
if (!fantasySeason) return [];
|
||||
|
||||
const simulator = new BracktSimulator(db);
|
||||
const results = await simulator.simulate(bracktSeason.id);
|
||||
await batchUpsertParticipantEVs(
|
||||
results.map((result) => ({
|
||||
participantId: result.participantId,
|
||||
sportsSeasonId: bracktSeason.id,
|
||||
probabilities: result.probabilities,
|
||||
scoringRules: {
|
||||
pointsFor1st: fantasySeason.pointsFor1st,
|
||||
pointsFor2nd: fantasySeason.pointsFor2nd,
|
||||
pointsFor3rd: fantasySeason.pointsFor3rd,
|
||||
pointsFor4th: fantasySeason.pointsFor4th,
|
||||
pointsFor5th: fantasySeason.pointsFor5th,
|
||||
pointsFor6th: fantasySeason.pointsFor6th,
|
||||
pointsFor7th: fantasySeason.pointsFor7th,
|
||||
pointsFor8th: fantasySeason.pointsFor8th,
|
||||
},
|
||||
source: "performance_model",
|
||||
})),
|
||||
db
|
||||
);
|
||||
await syncVorpForSeason(bracktSeason.id, db);
|
||||
|
||||
const participants = await db.query.seasonParticipants.findMany({
|
||||
where: eq(schema.seasonParticipants.sportsSeasonId, bracktSeason.id),
|
||||
});
|
||||
return participants.map((p) => ({
|
||||
participantId: p.id,
|
||||
expectedValue: Number(p.expectedValue ?? 0),
|
||||
vorpValue: Number(p.vorpValue ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveBracktForFantasySeason(
|
||||
fantasySeasonId: string,
|
||||
db: Db,
|
||||
trigger: "auto" | "manual",
|
||||
completedSportsSeasonId?: string
|
||||
) {
|
||||
const bracktSeason = await findPrivateBracktSeason(fantasySeasonId, db);
|
||||
if (!bracktSeason) return { status: "skipped" as const, reason: "no_brackt" };
|
||||
|
||||
const picks = await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, fantasySeasonId),
|
||||
with: { participant: { with: { sportsSeason: { with: { sport: true } }, results: true } } },
|
||||
});
|
||||
const nonBracktPicks = picks.filter((pick) => !isBracktSport(pick.participant.sportsSeason.sport));
|
||||
const ready = nonBracktPicks.every((pick) => {
|
||||
const hasFinalizedResult = pick.participant.results.some(
|
||||
(r) => r.finalPosition !== null && !r.isPartialScore
|
||||
);
|
||||
return hasFinalizedResult || pick.participant.sportsSeason.status === "completed";
|
||||
});
|
||||
if (!ready) return { status: "skipped" as const, reason: "incomplete" };
|
||||
|
||||
const [standings, bracktParticipants, fantasySeason] = await Promise.all([
|
||||
db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, fantasySeasonId) }),
|
||||
db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.sportsSeasonId, bracktSeason.id) }),
|
||||
db.query.seasons.findFirst({ where: eq(schema.seasons.id, fantasySeasonId), with: { league: true } }),
|
||||
]);
|
||||
if (standings.length === 0 || !fantasySeason) {
|
||||
return { status: "skipped" as const, reason: "missing_standings" };
|
||||
}
|
||||
|
||||
const participantByTeamId = new Map(bracktParticipants.map((p) => [p.externalId, p]));
|
||||
const desired = standings.map((standing) => {
|
||||
const participant = participantByTeamId.get(standing.teamId);
|
||||
if (!participant) throw new Error(`Missing Brackt participant for team ${standing.teamId}`);
|
||||
return { participantId: participant.id, finalPosition: standing.currentRank };
|
||||
});
|
||||
|
||||
const existingResults = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, bracktSeason.id),
|
||||
});
|
||||
if (existingResults.length > 0) {
|
||||
const existingByParticipantId = new Map(existingResults.map((r) => [r.participantId, r]));
|
||||
const differs = desired.some((placement) => {
|
||||
const existing = existingByParticipantId.get(placement.participantId);
|
||||
return existing && existing.finalPosition !== placement.finalPosition;
|
||||
});
|
||||
if (differs) {
|
||||
return { status: "blocked" as const, reason: "existing_results_differ" };
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const existingByParticipantId = new Map(existingResults.map((r) => [r.participantId, r]));
|
||||
for (const placement of desired) {
|
||||
const existing = existingByParticipantId.get(placement.participantId);
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.seasonParticipantResults)
|
||||
.set({
|
||||
finalPosition: placement.finalPosition,
|
||||
isPartialScore: false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.seasonParticipantResults.id, existing.id));
|
||||
} else {
|
||||
await db.insert(schema.seasonParticipantResults).values({
|
||||
participantId: placement.participantId,
|
||||
sportsSeasonId: bracktSeason.id,
|
||||
finalPosition: placement.finalPosition,
|
||||
isPartialScore: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ status: "completed", updatedAt: now })
|
||||
.where(eq(schema.sportsSeasons.id, bracktSeason.id));
|
||||
await recalculateStandings(fantasySeasonId, db);
|
||||
await createDailySnapshot(fantasySeasonId, db);
|
||||
await db.insert(schema.commissionerAuditLog).values({
|
||||
seasonId: fantasySeasonId,
|
||||
leagueId: fantasySeason.leagueId,
|
||||
actorUserId: trigger === "auto" ? "system" : "commissioner",
|
||||
actorDisplayName: trigger === "auto" ? "System" : "Commissioner",
|
||||
action: "brackt_resolved",
|
||||
affectedTeamIds: standings.map((s) => s.teamId),
|
||||
details: { trigger, completedSportsSeasonId },
|
||||
});
|
||||
|
||||
return { status: "resolved" as const };
|
||||
}
|
||||
|
||||
export async function maybeResolveCompletedBracktForSportsSeason(completedSportsSeasonId: string, providedDb?: Db) {
|
||||
const db = providedDb || database();
|
||||
const completedSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, completedSportsSeasonId),
|
||||
with: { sport: true },
|
||||
});
|
||||
if (!completedSeason || isBracktSport(completedSeason.sport)) return [];
|
||||
|
||||
const links = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, completedSportsSeasonId),
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const link of links) {
|
||||
results.push(await resolveBracktForFantasySeason(link.seasonId, db, "auto", completedSportsSeasonId));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
24
app/services/simulations/__tests__/brackt-simulator.test.ts
Normal file
24
app/services/simulations/__tests__/brackt-simulator.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { harvillePlacementProbabilities } from "../brackt-simulator";
|
||||
|
||||
describe("BracktSimulator", () => {
|
||||
it("returns uniform placement probabilities when all weights are zero", () => {
|
||||
const probabilities = harvillePlacementProbabilities([0, 0, 0, 0, 0, 0]);
|
||||
|
||||
expect(probabilities).toHaveLength(6);
|
||||
for (const row of probabilities) {
|
||||
expect(row).toHaveLength(6);
|
||||
for (const probability of row) {
|
||||
expect(probability).toBeCloseTo(1 / 6, 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives higher first-place probability to higher projected weights", () => {
|
||||
const probabilities = harvillePlacementProbabilities([10, 20, 40]);
|
||||
|
||||
expect(probabilities[2][0]).toBeGreaterThan(probabilities[1][0]);
|
||||
expect(probabilities[1][0]).toBeGreaterThan(probabilities[0][0]);
|
||||
expect(probabilities[0][0] + probabilities[1][0] + probabilities[2][0]).toBeCloseTo(1, 5);
|
||||
});
|
||||
});
|
||||
176
app/services/simulations/brackt-simulator.ts
Normal file
176
app/services/simulations/brackt-simulator.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { Simulator, SimulationProbabilities, SimulationResult } from "./types";
|
||||
|
||||
const PROB_KEYS = [
|
||||
"probFirst",
|
||||
"probSecond",
|
||||
"probThird",
|
||||
"probFourth",
|
||||
"probFifth",
|
||||
"probSixth",
|
||||
"probSeventh",
|
||||
"probEighth",
|
||||
] as const;
|
||||
|
||||
function emptyProbabilities(): SimulationProbabilities {
|
||||
return {
|
||||
probFirst: 0,
|
||||
probSecond: 0,
|
||||
probThird: 0,
|
||||
probFourth: 0,
|
||||
probFifth: 0,
|
||||
probSixth: 0,
|
||||
probSeventh: 0,
|
||||
probEighth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function harvillePlacementProbabilities(weights: number[]): number[][] {
|
||||
const n = weights.length;
|
||||
const maxPlaces = Math.min(8, n);
|
||||
if (n === 0) return [];
|
||||
|
||||
if (weights.every((w) => w <= 0)) {
|
||||
return weights.map(() =>
|
||||
Array.from({ length: maxPlaces }, () => Number((1 / n).toFixed(6)))
|
||||
);
|
||||
}
|
||||
|
||||
const probs = weights.map(() => Array.from({ length: maxPlaces }, () => 0));
|
||||
let states = new Map<number, number>([[0, 1]]);
|
||||
|
||||
for (let place = 0; place < maxPlaces; place++) {
|
||||
const next = new Map<number, number>();
|
||||
for (const [mask, stateProb] of states) {
|
||||
const remaining = weights
|
||||
.map((weight, index) => ({ weight: Math.max(0, weight), index }))
|
||||
.filter(({ index }) => (mask & (1 << index)) === 0);
|
||||
const total = remaining.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
for (const item of remaining) {
|
||||
const pickProb = total > 0 ? item.weight / total : 1 / remaining.length;
|
||||
const probability = stateProb * pickProb;
|
||||
probs[item.index][place] += probability;
|
||||
const nextMask = mask | (1 << item.index);
|
||||
next.set(nextMask, (next.get(nextMask) ?? 0) + probability);
|
||||
}
|
||||
}
|
||||
states = next;
|
||||
}
|
||||
|
||||
return probs;
|
||||
}
|
||||
|
||||
export class BracktSimulator implements Simulator {
|
||||
private readonly db: ReturnType<typeof database>;
|
||||
|
||||
constructor(providedDb?: ReturnType<typeof database>) {
|
||||
this.db = providedDb ?? database();
|
||||
}
|
||||
|
||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = this.db;
|
||||
const bracktSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
||||
with: { sport: true },
|
||||
});
|
||||
|
||||
if (!bracktSeason?.fantasySeasonId) {
|
||||
throw new Error("Brackt simulation requires a private sports season linked to a fantasy season");
|
||||
}
|
||||
|
||||
const fantasySeasonId = bracktSeason.fantasySeasonId;
|
||||
const [teams, bracktParticipants, seasonSports, draftPicks] = await Promise.all([
|
||||
db.query.teams.findMany({
|
||||
where: eq(schema.teams.seasonId, fantasySeasonId),
|
||||
orderBy: (teamsTable, { asc }) => [asc(teamsTable.name)],
|
||||
}),
|
||||
db.query.seasonParticipants.findMany({
|
||||
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
|
||||
}),
|
||||
db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.seasonId, fantasySeasonId),
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
}),
|
||||
db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, fantasySeasonId),
|
||||
with: { participant: { with: { sportsSeason: { with: { sport: true } } } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (teams.length < 6 || teams.length > 16) {
|
||||
throw new Error("Brackt supports leagues with 6 to 16 teams");
|
||||
}
|
||||
|
||||
const nonBracktSeasonIds = seasonSports
|
||||
.filter((link) => link.sportsSeason.sport.slug !== "brackt")
|
||||
.map((link) => link.sportsSeasonId);
|
||||
|
||||
const draftedIds = draftPicks.map((pick) => pick.participantId);
|
||||
const availableBySportsSeason = new Map<string, number>();
|
||||
|
||||
if (nonBracktSeasonIds.length > 0) {
|
||||
for (const nonBracktSeasonId of nonBracktSeasonIds) {
|
||||
const whereClause = draftedIds.length > 0
|
||||
? and(
|
||||
eq(schema.seasonParticipants.sportsSeasonId, nonBracktSeasonId),
|
||||
notInArray(schema.seasonParticipants.id, draftedIds)
|
||||
)
|
||||
: eq(schema.seasonParticipants.sportsSeasonId, nonBracktSeasonId);
|
||||
const available = await db.query.seasonParticipants.findMany({ where: whereClause });
|
||||
const average = available.length > 0
|
||||
? available.reduce((sum, p) => sum + Number(p.expectedValue ?? 0), 0) / available.length
|
||||
: 0;
|
||||
availableBySportsSeason.set(nonBracktSeasonId, average);
|
||||
}
|
||||
}
|
||||
|
||||
const picksByTeam = new Map<string, typeof draftPicks>();
|
||||
for (const pick of draftPicks) {
|
||||
const existing = picksByTeam.get(pick.teamId) ?? [];
|
||||
existing.push(pick);
|
||||
picksByTeam.set(pick.teamId, existing);
|
||||
}
|
||||
|
||||
const weights = teams.map((team) => {
|
||||
const teamPicks = picksByTeam.get(team.id) ?? [];
|
||||
const draftedBySportsSeason = new Set<string>();
|
||||
let projection = 0;
|
||||
|
||||
for (const pick of teamPicks) {
|
||||
if (pick.participant.sportsSeason.sport.slug === "brackt") continue;
|
||||
draftedBySportsSeason.add(pick.participant.sportsSeasonId);
|
||||
projection += Number(pick.participant.expectedValue ?? 0);
|
||||
}
|
||||
|
||||
for (const nonBracktSeasonId of nonBracktSeasonIds) {
|
||||
if (!draftedBySportsSeason.has(nonBracktSeasonId)) {
|
||||
projection += availableBySportsSeason.get(nonBracktSeasonId) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, projection);
|
||||
});
|
||||
|
||||
const placementProbabilities = harvillePlacementProbabilities(weights);
|
||||
const participantByTeamId = new Map(bracktParticipants.map((p) => [p.externalId, p]));
|
||||
|
||||
return teams.map((team, teamIndex) => {
|
||||
const participant = participantByTeamId.get(team.id);
|
||||
if (!participant) {
|
||||
throw new Error(`Missing Brackt participant for team ${team.name}`);
|
||||
}
|
||||
const probabilities = emptyProbabilities();
|
||||
for (let i = 0; i < Math.min(PROB_KEYS.length, placementProbabilities[teamIndex].length); i++) {
|
||||
probabilities[PROB_KEYS[i]] = Number(placementProbabilities[teamIndex][i].toFixed(4));
|
||||
}
|
||||
return {
|
||||
participantId: participant.id,
|
||||
probabilities,
|
||||
source: "brackt_harville",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import { WNBASimulator } from "./wnba-simulator";
|
|||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
import { NCAAFootballSimulator } from "./ncaa-football-simulator";
|
||||
import { LLWSSimulator } from "./llws-simulator";
|
||||
import { BracktSimulator } from "./brackt-simulator";
|
||||
|
||||
export const SIMULATOR_TYPES = [
|
||||
"f1_standings",
|
||||
|
|
@ -48,6 +49,7 @@ export const SIMULATOR_TYPES = [
|
|||
"cs2_major_qualifying_points",
|
||||
"ncaa_football_bracket",
|
||||
"llws_bracket",
|
||||
"brackt",
|
||||
] as const;
|
||||
|
||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
||||
|
|
@ -158,6 +160,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
},
|
||||
create: () => new LLWSSimulator(),
|
||||
},
|
||||
brackt: {
|
||||
info: {
|
||||
name: "Brackt Harville Model",
|
||||
description: "Projects league manager standings from drafted non-Brackt EVs and simulates final league placement probabilities.",
|
||||
},
|
||||
create: () => new BracktSimulator(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getSimulator(simulatorType: SimulatorType): Simulator {
|
||||
|
|
@ -174,4 +183,3 @@ export function getSimulator(simulatorType: SimulatorType): Simulator {
|
|||
export function getSimulatorInfo(simulatorType: SimulatorType): SimulatorInfo | null {
|
||||
return REGISTRY[simulatorType]?.info ?? null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
|
|||
cs2_major_qualifying_points: null,
|
||||
ncaa_football_bracket: null,
|
||||
llws_bracket: null,
|
||||
brackt: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"cs2_major_qualifying_points",
|
||||
"ncaa_football_bracket",
|
||||
"llws_bracket",
|
||||
"brackt",
|
||||
]);
|
||||
|
||||
export const tournamentStatusEnum = pgEnum("tournament_status", [
|
||||
|
|
@ -354,6 +355,9 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
|||
sportId: uuid("sport_id")
|
||||
.notNull()
|
||||
.references(() => sports.id, { onDelete: "cascade" }),
|
||||
fantasySeasonId: uuid("fantasy_season_id").references(() => seasons.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
year: integer("year").notNull(),
|
||||
startDate: date("start_date"),
|
||||
|
|
@ -904,6 +908,10 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
|||
fields: [sportsSeasons.sportId],
|
||||
references: [sports.id],
|
||||
}),
|
||||
fantasySeason: one(seasons, {
|
||||
fields: [sportsSeasons.fantasySeasonId],
|
||||
references: [seasons.id],
|
||||
}),
|
||||
participants: many(seasonParticipants),
|
||||
seasonTemplateSports: many(seasonTemplateSports),
|
||||
seasonSports: many(seasonSports),
|
||||
|
|
@ -999,6 +1007,7 @@ export const seasonsRelations = relations(seasons, ({ one, many }) => ({
|
|||
}),
|
||||
teams: many(teams),
|
||||
seasonSports: many(seasonSports),
|
||||
privateSportsSeasons: many(sportsSeasons),
|
||||
}));
|
||||
|
||||
export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({
|
||||
|
|
@ -1514,6 +1523,7 @@ export const auditActionEnum = pgEnum("audit_action", [
|
|||
"force_manual_pick",
|
||||
"draft_pick_changed",
|
||||
"time_bank_edited",
|
||||
"brackt_resolved",
|
||||
]);
|
||||
|
||||
export const commissionerAuditLog = pgTable("commissioner_audit_log", {
|
||||
|
|
|
|||
105
docs/agents/brackt-sport-implementation-plan.md
Normal file
105
docs/agents/brackt-sport-implementation-plan.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Brackt as a Draftable Sport Implementation Plan
|
||||
|
||||
This plan is the implementation source of truth for making Brackt a league-private meta-sport. The design reference is [Brackt sport](brackt-sport.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Implement Brackt as a league-private meta-sport with automatic final resolution. When any non-Brackt sports season is marked completed, the app checks linked fantasy seasons. If every drafted non-Brackt participant is finalized, it resolves that league's private Brackt season automatically. Commissioners still get a manual fallback action for repair and idempotent retry.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add schema support:
|
||||
- Add `sportsSeasons.fantasySeasonId`.
|
||||
- Add `brackt` to `simulatorTypeEnum`.
|
||||
- Add `brackt_resolved` to `auditActionEnum`.
|
||||
- Generate migration with `npm run db:generate`.
|
||||
- Add an admin seed/repair UI action:
|
||||
- Idempotently creates or repairs the global `Brackt` sport and global template sports season.
|
||||
- Template has no participants and a long draft window.
|
||||
- Update draftable sports selection:
|
||||
- Hide per-league Brackt copies from all sport pickers with `fantasySeasonId IS NULL`.
|
||||
- Allow the global Brackt template through league creation/settings even with zero participants.
|
||||
- Add Brackt lifecycle helpers:
|
||||
- On league creation or pre-draft settings update, replace the Brackt template link with a private sports season copy.
|
||||
- Insert manager participants directly into `seasonParticipants` with `externalId = team.id`.
|
||||
- Auto-sync private Brackt participants when pre-draft teams are added, removed, or renamed.
|
||||
- Remove the private Brackt season if Brackt is removed before draft start.
|
||||
|
||||
## EV And Draft Behavior
|
||||
|
||||
- Add `BracktSimulator`:
|
||||
- Supports all current league sizes, 6-16 teams.
|
||||
- Uses Harville probabilities for `N` teams and maps only available scoring tiers.
|
||||
- If all projected weights are zero, returns uniform placement probabilities.
|
||||
- Projects each team from drafted non-Brackt EV plus average available EV for each unpicked non-Brackt sport.
|
||||
- Add `runBracktHarvilleForFantasySeason(fantasySeasonId, db)`:
|
||||
- Requires an explicit DB instance.
|
||||
- Runs the simulator, batch-upserts EVs, syncs VORP, rereads stored VORP, and returns `{ participantId, expectedValue, vorpValue }`.
|
||||
- Refactor only the needed EV path to accept an optional provided DB:
|
||||
- `batchUpsertParticipantEVs(inputs, db?)`
|
||||
- `syncVorpForSeason(sportsSeasonId, db?)`
|
||||
- Run Brackt Harville:
|
||||
- Await before autopick participant selection.
|
||||
- Fire-and-forget after manual picks commit.
|
||||
- Await after private Brackt season creation/sync.
|
||||
- Emit `brackt-evs-updated` to the draft room with updated `expectedValue` and `vorpValue`.
|
||||
- Draft room client:
|
||||
- Patches Brackt VORPs from socket updates.
|
||||
- Sorts by effective server VORP.
|
||||
- Never computes Brackt VORP client-side.
|
||||
|
||||
## Automatic Resolution
|
||||
|
||||
- Add a centralized service:
|
||||
- `maybeResolveCompletedBracktForSportsSeason(completedSportsSeasonId, db)`
|
||||
- Called after any non-Brackt sports season status transitions to `completed`.
|
||||
- Hook all completion paths through this helper:
|
||||
- Admin sports-season edit when status becomes `completed`.
|
||||
- `finalize-standings`.
|
||||
- Bracket/qualifying finalization paths that mark or imply sports-season completion.
|
||||
- Any future model helper for status updates should call this from one shared status-transition function.
|
||||
- Automatic resolution flow:
|
||||
- Find fantasy seasons linked to the completed non-Brackt sports season.
|
||||
- For each fantasy season, find its private Brackt season.
|
||||
- Skip leagues without Brackt or already resolved Brackt.
|
||||
- Require every drafted non-Brackt participant to have a finalized, non-partial result.
|
||||
- If ready, resolve Brackt from `teamStandings.currentRank`, mapping team ID to Brackt participant via `externalId`.
|
||||
- Preserve tied ranks as tied `finalPosition` values.
|
||||
- Mark the private Brackt sports season `completed`.
|
||||
- Recalculate standings and create a daily snapshot.
|
||||
- Write audit log `brackt_resolved` with `{ trigger: "auto", completedSportsSeasonId }`.
|
||||
- Manual fallback:
|
||||
- Keep a commissioner-only league settings action to retry resolution.
|
||||
- Re-run is idempotent only: allow if placements match existing Brackt results; block if they would differ.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit tests:
|
||||
- Brackt Harville zero-weight uniform probabilities.
|
||||
- Brackt projection from drafted EV plus average available EV.
|
||||
- Private Brackt creation and team participant sync.
|
||||
- Picker hides private copies and allows the zero-participant global template.
|
||||
- EV/VORP path works with provided DB.
|
||||
- Automatic resolver skips incomplete leagues and resolves ready leagues.
|
||||
- Automatic resolver ignores leagues without Brackt and ignores Brackt-triggered recursion.
|
||||
- Tied-rank resolution and idempotent re-run behavior.
|
||||
- Route/action tests:
|
||||
- League creation with Brackt creates private season and participants.
|
||||
- League settings add/remove/sync Brackt pre-draft.
|
||||
- Admin seed/repair action is idempotent.
|
||||
- Sports-season completion triggers automatic Brackt check.
|
||||
- Manual commissioner retry works only when idempotent or unresolved.
|
||||
- Draft tests:
|
||||
- Autopick updates Brackt EV before top-VORP selection.
|
||||
- Manual pick emits Brackt EV update after commit.
|
||||
- Client socket update patches Brackt VORP sorting.
|
||||
- Verification:
|
||||
- Run `npm run typecheck`, `npm run lint`, and targeted `npm run test:run`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Brackt supports 6-16 team leagues.
|
||||
- Brackt template remains a normal sport picker option with a Brackt-specific zero-participant exception.
|
||||
- Pre-draft team edits auto-sync Brackt participants.
|
||||
- Final tied standings remain tied for Brackt scoring.
|
||||
- Automatic resolution is primary; commissioner action is fallback/repair.
|
||||
8
drizzle/0094_curved_nick_fury.sql
Normal file
8
drizzle/0094_curved_nick_fury.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
ALTER TYPE "public"."audit_action" ADD VALUE 'brackt_resolved';--> statement-breakpoint
|
||||
ALTER TYPE "public"."simulator_type" ADD VALUE 'brackt';--> statement-breakpoint
|
||||
ALTER TABLE "sports_seasons" ADD COLUMN "fantasy_season_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "sports_seasons" ADD CONSTRAINT "sports_seasons_fantasy_season_id_seasons_id_fk" FOREIGN KEY ("fantasy_season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
5723
drizzle/meta/0094_snapshot.json
Normal file
5723
drizzle/meta/0094_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -659,6 +659,13 @@
|
|||
"when": 1777783789103,
|
||||
"tag": "0093_ambiguous_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 94,
|
||||
"version": "7",
|
||||
"when": 1777920441672,
|
||||
"tag": "0094_curved_nick_fury",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ interface ServerToClientEvents {
|
|||
"participant-removed-from-queues": (data: { participantId: string }) => void;
|
||||
"queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void;
|
||||
"watchlist-updated": (data: { participantIds: string[] }) => void;
|
||||
"brackt-evs-updated": (data: {
|
||||
updates: Array<{ participantId: string; expectedValue: number; vorpValue: number }>;
|
||||
}) => void;
|
||||
"draft-state-sync": (data: {
|
||||
currentPickNumber: number;
|
||||
isPaused: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue