Add Brackt as a league-private meta-sport (#200) (#377)

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:
Chris Parsons 2026-05-04 20:31:44 -07:00 committed by GitHub
parent 01e2f04656
commit 5b03b22267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 6796 additions and 154 deletions

View file

@ -41,6 +41,7 @@ interface UseDraftSocketEventsParams {
setWatchedParticipantIds: (value: Set<string>) => void; setWatchedParticipantIds: (value: Set<string>) => void;
setRoomClosed: (value: boolean) => void; setRoomClosed: (value: boolean) => void;
setIsSyncing: (value: boolean) => void; setIsSyncing: (value: boolean) => void;
setBracktVorps: (fn: (prev: Map<string, number>) => Map<string, number>) => void;
} }
export function useDraftSocketEvents({ export function useDraftSocketEvents({
@ -70,6 +71,7 @@ export function useDraftSocketEvents({
setWatchedParticipantIds, setWatchedParticipantIds,
setRoomClosed, setRoomClosed,
setIsSyncing, setIsSyncing,
setBracktVorps,
}: UseDraftSocketEventsParams) { }: UseDraftSocketEventsParams) {
useEffect(() => { useEffect(() => {
type PickMadePayload = { type PickMadePayload = {
@ -238,6 +240,18 @@ export function useDraftSocketEvents({
setRoomClosed(true); 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("pick-made", handlePickMade as (data: unknown) => void);
on("timer-update", handleTimerUpdate as (data: unknown) => void); on("timer-update", handleTimerUpdate as (data: unknown) => void);
on("draft-paused", handleDraftPaused 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("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void); on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void); on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
on("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
return () => { return () => {
off("pick-made", handlePickMade as (data: unknown) => void); 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("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void); off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
off("draft-room-closed", handleDraftRoomClosed 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]); }, [on, off, socketVersion]);

View file

@ -17,6 +17,7 @@ export const AUDIT_ACTION_LABELS: Record<string, string> = {
force_manual_pick: "Forced Manual Pick", force_manual_pick: "Forced Manual Pick",
draft_pick_changed: "Pick Replaced", draft_pick_changed: "Pick Replaced",
time_bank_edited: "Time Bank Edited", time_bank_edited: "Time Bank Edited",
brackt_resolved: "Brackt Resolved",
}; };
const SCORING_FIELD_LABELS: Record<string, string> = { 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"; return parts.length > 0 ? parts.join(" | ") : "Sports updated";
} }
case "brackt_resolved":
return d.trigger === "auto" ? "Brackt resolved automatically" : "Brackt resolution retried";
default: default:
return (entry.action as string).replace(/_/g, " "); return (entry.action as string).replace(/_/g, " ");
} }

View file

@ -48,7 +48,9 @@ export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData {
const fd = new FormData(); const fd = new FormData();
fd.append("name", s.leagueName); fd.append("name", s.leagueName);
fd.append("teamCount", s.teamCount.toString()); fd.append("teamCount", s.teamCount.toString());
fd.append("templateId", s.selectedTemplate); if (s.selectedTemplate && s.selectedTemplate !== "customize") {
fd.append("templateId", s.selectedTemplate);
}
fd.append("draftRounds", s.draftRounds.toString()); fd.append("draftRounds", s.draftRounds.toString());
if (s.draftDate && s.draftTime) { if (s.draftDate && s.draftTime) {
fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString()); fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString());

View file

@ -9,6 +9,7 @@ import { getParticipantsForSeasonWithSports } from "./season-participant";
import { getSeasonSportsSimple } from "./season-sport"; import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; 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 * 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); 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 // Check if next team has autodraft enabled and trigger immediately
// Only run the chain from the top-level call to prevent recursion // Only run the chain from the top-level call to prevent recursion
if (!isDraftComplete && params.chainEnabled !== false) { if (!isDraftComplete && params.chainEnabled !== false) {

View file

@ -57,10 +57,13 @@ export interface UpdateProbabilityInput {
* *
* Call this after any operation that changes EVs for seasonParticipants in the season. * Call this after any operation that changes EVs for seasonParticipants in the season.
*/ */
export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> { export async function syncVorpForSeason(
const db = database(); 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; if (allEvs.length === 0) return;
const sorted = [...allEvs].toSorted( const sorted = [...allEvs].toSorted(
@ -239,9 +242,10 @@ export async function getParticipantEV(
* Get all participant EVs for a sports season * Get all participant EVs for a sports season
*/ */
export async function getAllParticipantEVsForSeason( export async function getAllParticipantEVsForSeason(
sportsSeasonId: string sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<ParticipantEV[]> { ): Promise<ParticipantEV[]> {
const db = database(); const db = providedDb || database();
return db return db
.select() .select()
.from(seasonParticipantExpectedValues) .from(seasonParticipantExpectedValues)
@ -280,9 +284,10 @@ export async function deleteParticipantEV(
* All records succeed or all fail together. * All records succeed or all fail together.
*/ */
export async function batchUpsertParticipantEVs( export async function batchUpsertParticipantEVs(
inputs: CreateProbabilityInput[] inputs: CreateProbabilityInput[],
providedDb?: ReturnType<typeof database>
): Promise<ParticipantEV[]> { ): Promise<ParticipantEV[]> {
const db = database(); const db = providedDb || database();
const results: ParticipantEV[] = []; const results: ParticipantEV[] = [];
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@ -355,7 +360,7 @@ export async function batchUpsertParticipantEVs(
// Sync VORP for all affected seasons // Sync VORP for all affected seasons
const uniqueSeasonIds = [...new Set(inputs.map((i) => i.sportsSeasonId))]; 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; return results;
} }

View file

@ -775,6 +775,7 @@ export async function finalizeQualifyingPoints(
.update(schema.sportsSeasons) .update(schema.sportsSeasons)
.set({ .set({
qualifyingPointsFinalized: true, qualifyingPointsFinalized: true,
status: "completed",
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.sportsSeasons.id, sportsSeasonId)); .where(eq(schema.sportsSeasons.id, sportsSeasonId));
@ -816,10 +817,11 @@ export async function processSeasonStandings(
// Group participants by position to handle ties // Group participants by position to handle ties
const positionGroups: Record<number, string[]> = {}; const positionGroups: Record<number, string[]> = {};
const processedParticipantIds = new Set<string>();
for (const result of seasonResults) { for (const result of seasonResults) {
const position = result.currentPosition; const position = result.currentPosition;
if (position === null) continue; // Skip participants without a position if (position === null) continue; // handled after the loop
if (!positionGroups[position]) { if (!positionGroups[position]) {
positionGroups[position] = []; positionGroups[position] = [];
@ -838,8 +840,14 @@ export async function processSeasonStandings(
for (const position of positions) { for (const position of positions) {
const participantIds = positionGroups[position]; const participantIds = positionGroups[position];
// Stop if we've gone beyond the top 8 fantasy placements if (currentPlacement > 8) {
if (currentPlacement > 8) break; // 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 // Calculate how many placements this group shares
const participantsInGroup = participantIds.length; const participantsInGroup = participantIds.length;
@ -848,47 +856,33 @@ export async function processSeasonStandings(
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1); const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1; const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
// Determine if we should assign placements to this group // All participants tied at this position get the first placement in the range
if (currentPlacement <= 8) { // (e.g., if 4 people tie for 5th place, they all get placement 5)
// All participants tied at this position get the first placement in the range // The scoring system will handle averaging the points for positions 5, 6, 7, 8
// (e.g., if 4 people tie for 5th place, they all get placement 5) for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
// The scoring system will handle averaging the points for positions 5, 6, 7, 8 const participantId = participantIds[i];
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) { processedParticipantIds.add(participantId);
const participantId = participantIds[i]; await upsertParticipantResult(participantId, sportsSeasonId, currentPlacement, db);
await upsertParticipantResult( }
participantId,
sportsSeasonId,
currentPlacement,
db
);
}
// If there are more participants than scoring positions, assign 0 to the rest // If there are more participants than scoring positions, assign 0 to the rest
for (let i = actualParticipantsScoring; i < participantIds.length; i++) { for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
const participantId = participantIds[i]; const participantId = participantIds[i];
await upsertParticipantResult( processedParticipantIds.add(participantId);
participantId, await upsertParticipantResult(participantId, sportsSeasonId, 0, db);
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
);
}
} }
// Move to next placement group // Move to next placement group
currentPlacement += participantsInGroup; 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 // Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });

View file

@ -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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points"; import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
export async function countSportsSeasons(): Promise<number> { export async function countSportsSeasons(): Promise<number> {
const db = database(); 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() { export async function findDraftableSportsSeasons() {
const db = database(); const db = database();
const today = sql`CURRENT_DATE`; const today = sql`CURRENT_DATE`;
@ -129,7 +136,8 @@ export async function findDraftableSportsSeasons() {
where: (ss, { and }) => where: (ss, { and }) =>
and( and(
lte(ss.draftOn, today), 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)], orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)],
with: { with: {
@ -148,11 +156,20 @@ export async function updateSportsSeason(
data: Partial<NewSportsSeason> data: Partial<NewSportsSeason>
): Promise<SportsSeason> { ): Promise<SportsSeason> {
const db = database(); const db = database();
const previous = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, id),
columns: { status: true },
});
const [sportsSeason] = await db const [sportsSeason] = await db
.update(schema.sportsSeasons) .update(schema.sportsSeasons)
.set({ ...data, updatedAt: new Date() }) .set({ ...data, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, id)) .where(eq(schema.sportsSeasons.id, id))
.returning(); .returning();
if (previous && previous.status !== "completed" && sportsSeason.status === "completed") {
await maybeResolveCompletedBracktForSportsSeason(sportsSeason.id, db);
}
return sportsSeason; return sportsSeason;
} }

View file

@ -1,4 +1,4 @@
import { Link } from "react-router"; import { Form, Link } from "react-router";
import { useState } from "react"; import { useState } from "react";
import type { Route } from "./+types/admin._index"; import type { Route } from "./+types/admin._index";
@ -17,6 +17,16 @@ import {
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink, Users } from "lucide-react"; 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 { export function meta(): Route.MetaDescriptors {
return [{ title: "Admin - Brackt" }]; 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; const { stats, seasonStatusCounts, upcomingEvents, today, tomorrow } = loaderData;
// Serialize dates to strings for the client component // 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" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</Button> </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> </CardContent>
</Card> </Card>

View file

@ -52,6 +52,7 @@ import { logger } from "~/lib/logger";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); 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() }) .set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId)); .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 // Recalculate standings for all affected fantasy seasons
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
@ -730,6 +736,7 @@ export async function action({ request, params }: Route.ActionArgs) {
await recalculateStandings(seasonSport.seasonId, db); await recalculateStandings(seasonSport.seasonId, db);
await createDailySnapshot(seasonSport.seasonId, db); await createDailySnapshot(seasonSport.seasonId, db);
} }
await maybeResolveCompletedBracktForSportsSeason(params.id, db);
return { return {
success: `Bracket finalized! All placements calculated and standings updated.`, success: `Bracket finalized! All placements calculated and standings updated.`,

View file

@ -11,6 +11,7 @@ import {
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { getQPStandings } from "~/models/qualifying-points"; import { getQPStandings } from "~/models/qualifying-points";
import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { upsertTournament, findTournamentsBySport, getTournamentById } from "~/models/tournament"; import { upsertTournament, findTournamentsBySport, getTournamentById } from "~/models/tournament";
import { extractTournamentIdentity } from "~/lib/tournament-identity"; import { extractTournamentIdentity } from "~/lib/tournament-identity";
@ -87,6 +88,7 @@ export async function action({ request, params }: Route.ActionArgs) {
if (intent === "finalize-qp") { if (intent === "finalize-qp") {
try { try {
await finalizeQualifyingPoints(params.id); await finalizeQualifyingPoints(params.id);
await maybeResolveCompletedBracktForSportsSeason(params.id);
return { success: "Qualifying points finalized and fantasy placements assigned to top 8!" }; return { success: "Qualifying points finalized and fantasy placements assigned to top 8!" };
} catch (error) { } catch (error) {
logger.error("Error finalizing qualifying points:", error); logger.error("Error finalizing qualifying points:", error);

View file

@ -1,7 +1,7 @@
import { Link } from "react-router"; import { Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons"; import type { Route } from "./+types/admin.sports-seasons";
import { findAllSportsSeasons } from "~/models/sports-season"; import { findAllAdminSportsSeasons } from "~/models/sports-season";
import { import {
Card, Card,
CardContent, CardContent,
@ -26,7 +26,7 @@ export function meta(): Route.MetaDescriptors {
} }
export async function loader() { export async function loader() {
const sportsSeasons = await findAllSportsSeasons(); const sportsSeasons = await findAllAdminSportsSeasons();
return { sportsSeasons }; return { sportsSeasons };
} }

View file

@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { 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 // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { 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) }); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
if (!freshSeason?.draftPaused) { if (!freshSeason?.draftPaused) {
await checkAndTriggerNextAutodraft({ await checkAndTriggerNextAutodraft({

View file

@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils"; import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { 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 // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { 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) }); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
if (!freshSeason?.draftPaused) { if (!freshSeason?.draftPaused) {
await checkAndTriggerNextAutodraft({ await checkAndTriggerNextAutodraft({

View file

@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -97,11 +98,8 @@ export async function action(args: ActionFunctionArgs) {
}, },
}); });
// Emit socket events
try { try {
const io = getSocketIO(); getSocketIO().to(`draft-${seasonId}`).emit("draft-rolled-back", {
io.to(`draft-${seasonId}`).emit("draft-rolled-back", {
seasonId, seasonId,
pickNumber, pickNumber,
teamId: rollbackSlot?.teamId, teamId: rollbackSlot?.teamId,
@ -110,5 +108,14 @@ export async function action(args: ActionFunctionArgs) {
logger.error("Socket.IO error:", error); 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 }); return Response.json({ success: true, pickNumber });
} }

View file

@ -195,6 +195,30 @@ export default function HowToPlay() {
</div> </div>
</section> </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 */} {/* During the Season */}
<section> <section>
<h2 className="text-2xl font-semibold mb-3">After the Draft</h2> <h2 className="text-2xl font-semibold mb-3">After the Draft</h2>

View file

@ -179,7 +179,7 @@ export default function DraftRoom() {
season, season,
draftSlots, draftSlots,
draftPicks, draftPicks,
availableParticipants, availableParticipants: initialAvailableParticipants,
userTeam, userTeam,
userQueue, userQueue,
userWatchlist, userWatchlist,
@ -191,6 +191,17 @@ export default function DraftRoom() {
ownerMap, ownerMap,
teamTimezoneMap, teamTimezoneMap,
} = useLoaderData<typeof loader>(); } = 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 navigate = useNavigate();
const { revalidate, state: revalidatorState } = useRevalidator(); const { revalidate, state: revalidatorState } = useRevalidator();
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id); 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 participantRanks = useMemo(() => {
const ranks = new Map<string, { overallRank: number; sportRank: number }>(); const ranks = new Map<string, { overallRank: number; sportRank: number }>();
const sportCounters = new Map<string, number>(); const sportCounters = new Map<string, number>();
availableParticipants.forEach((p, i) => { sortedAvailableParticipants.forEach((p, i) => {
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1; const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
sportCounters.set(p.sport.id, sportIdx); sportCounters.set(p.sport.id, sportIdx);
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx }); ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
}); });
return ranks; return ranks;
}, [availableParticipants]); }, [sortedAvailableParticipants]);
// Calculate draft eligibility for current user's team // Calculate draft eligibility for current user's team
const eligibility = useMemo(() => { const eligibility = useMemo(() => {
@ -472,6 +483,7 @@ export default function DraftRoom() {
setWatchedParticipantIds, setWatchedParticipantIds,
setRoomClosed, setRoomClosed,
setIsSyncing, setIsSyncing,
setBracktVorps,
}); });
// Persist sidebar collapsed state // Persist sidebar collapsed state
@ -1063,7 +1075,7 @@ export default function DraftRoom() {
// Filter participants based on search, sport, drafted status, and eligibility // Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(() => { const filteredParticipants = useMemo(() => {
const sportFilterSet = new Set(sportFilters); const sportFilterSet = new Set(sportFilters);
return availableParticipants.filter((participant) => { return sortedAvailableParticipants.filter((participant) => {
// Drafted filter - hide drafted participants by default // Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) { if (hideDrafted && draftedParticipantIds.has(participant.id)) {
return false; return false;
@ -1096,7 +1108,7 @@ export default function DraftRoom() {
} }
return true; 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 // Shared component props — defined once to avoid duplication between desktop and mobile layouts
const handleHideCompletedSportsChange = useCallback((hide: boolean) => { const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
@ -1203,7 +1215,7 @@ export default function DraftRoom() {
const queueSectionProps = userTeam const queueSectionProps = userTeam
? { ? {
queue, queue,
availableParticipants, availableParticipants: sortedAvailableParticipants,
canPick, canPick,
onRemoveFromQueue: handleRemoveFromQueue, onRemoveFromQueue: handleRemoveFromQueue,
onReorder: handleReorderQueue, onReorder: handleReorderQueue,
@ -1601,7 +1613,7 @@ export default function DraftRoom() {
}} }}
title="Force Manual Pick" title="Force Manual Pick"
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`} description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
participants={availableParticipants} participants={sortedAvailableParticipants}
draftedParticipantIds={draftedParticipantIds} draftedParticipantIds={draftedParticipantIds}
eligibility={forcePickEligibility} eligibility={forcePickEligibility}
uniqueSports={uniqueSports} uniqueSports={uniqueSports}
@ -1616,7 +1628,7 @@ export default function DraftRoom() {
}} }}
title="Replace Pick" title="Replace Pick"
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`} description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
participants={availableParticipants} participants={sortedAvailableParticipants}
draftedParticipantIds={draftedParticipantIds} draftedParticipantIds={draftedParticipantIds}
allowParticipantId={replacePickSlot?.oldParticipantId} allowParticipantId={replacePickSlot?.oldParticipantId}
currentParticipantId={replacePickSlot?.oldParticipantId} currentParticipantId={replacePickSlot?.oldParticipantId}

View file

@ -72,6 +72,11 @@ import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings"; import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker"; import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker";
import { toast } from "sonner"; import { toast } from "sonner";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
syncPrivateBracktParticipants,
} from "~/services/brackt.server";
function isValidHHMM(s: string): boolean { function isValidHHMM(s: string): boolean {
return /^\d{2}:\d{2}$/.test(s); 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` }]; return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
} }
const BRACKT_SLUG = "brackt";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers }); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null; 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 // Get current season with sports and draftable seasons in parallel
const [season, draftableSportsSeasons] = await Promise.all([ const [season, draftableSportsSeasons] = await Promise.all([
findCurrentSeasonWithSports(leagueId), findCurrentSeasonWithSports(leagueId),
@ -120,7 +129,10 @@ export async function loader(args: Route.LoaderArgs) {
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id)); const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
const linkedButNotDraftable = (season?.seasonSports ?? []) const linkedButNotDraftable = (season?.seasonSports ?? [])
.map((s) => s.sportsSeason) .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 allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : []; const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
@ -173,7 +185,10 @@ export async function loader(args: Route.LoaderArgs) {
teams, teams,
teamCount: teams.length, teamCount: teams.length,
teamsWithOwners, 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, draftSlots,
isAdmin, isAdmin,
allUsers, allUsers,
@ -672,11 +687,12 @@ export async function action(args: Route.ActionArgs) {
// Handle sports changes (only valid in pre_draft) // Handle sports changes (only valid in pre_draft)
if (season.status === "pre_draft") { if (season.status === "pre_draft") {
const selectedSports = formData.getAll("sportsSeasons"); const selectedSports = formData.getAll("sportsSeasons").map(String);
const currentSportIds = new Set( const currentSportIds = new Set(
season.seasonSports?.map((s) => s.sportsSeason.id) || [] 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) { for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) { if (!newSportIds.has(sportId)) {
@ -689,7 +705,7 @@ export async function action(args: Route.ActionArgs) {
if (!currentSportIds.has(sportId)) { if (!currentSportIds.has(sportId)) {
sportsToAdd.push({ sportsToAdd.push({
seasonId: season.id, seasonId: season.id,
sportsSeasonId: sportId as string, sportsSeasonId: sportId,
}); });
} }
} }
@ -769,15 +785,20 @@ export async function action(args: Route.ActionArgs) {
})) }))
); );
}); });
await syncPrivateBracktParticipants(season.id);
} else if (newTeamCount < currentTeamCount) { } 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) // Remove teams without owners (from the end)
const teamsToRemove = teams const teamsToRemove = teams
.filter(t => t.ownerId === null) .filter(t => t.ownerId === null)
.slice(-(currentTeamCount - newTeamCount)); .slice(-(currentTeamCount - newTeamCount));
for (const team of teamsToRemove) { for (const team of teamsToRemove) {
await deleteTeam(team.id); await deleteTeam(team.id);
} }
await syncPrivateBracktParticipants(season.id);
} }
} }
} }
@ -886,7 +907,24 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
return "standard"; return "standard";
}); });
const [selectedSports, setSelectedSports] = useState<Set<string>>( 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[]>( const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0 draftSlots.length > 0
@ -937,6 +975,18 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
// The form submission will handle the actual deletion // 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 moveDraftSlot = (fromIndex: number, toIndex: number) => {
const newOrder = [...draftOrderTeams]; const newOrder = [...draftOrderTeams];
const [movedTeam] = newOrder.splice(fromIndex, 1); const [movedTeam] = newOrder.splice(fromIndex, 1);
@ -1311,8 +1361,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
Sports Seasons ({selectedSports.size} selected) Sports Seasons ({selectedSports.size} selected)
</Label> </Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2"> <div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? ( {displaySportsSeasons.length > 0 ? (
allSportsSeasons.map((ss) => ( displaySportsSeasons.map((ss) => (
<div key={ss.id} className="flex items-center space-x-2"> <div key={ss.id} className="flex items-center space-x-2">
<Checkbox <Checkbox
id={ss.id} id={ss.id}
@ -1326,7 +1376,13 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
htmlFor={ss.id} htmlFor={ss.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`} className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
> >
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year}) {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"> <Badge variant="outline" className="ml-2 text-xs">
{ss.scoringType.replace("_", " ")} {ss.scoringType.replace("_", " ")}
</Badge> </Badge>

View file

@ -15,7 +15,7 @@ import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user"; import { findUserById, updateUser } from "~/models/user";
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names"; import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
import { format } from "date-fns"; 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 { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@ -36,6 +36,10 @@ import { WizardStepper } from "~/components/league/WizardStepper";
import { ReviewSection } from "~/components/league/ReviewSection"; import { ReviewSection } from "~/components/league/ReviewSection";
import { StepperInput } from "~/components/league/StepperInput"; import { StepperInput } from "~/components/league/StepperInput";
import { WizardAuthForm } from "~/components/league/WizardAuthForm"; import { WizardAuthForm } from "~/components/league/WizardAuthForm";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
} from "~/services/brackt.server";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -44,6 +48,7 @@ type SportSeason = {
name: string; name: string;
year: number; year: number;
scoringType: string; scoringType: string;
fantasySeasonId: string | null;
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null }; sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
participantCount: number; participantCount: number;
}; };
@ -56,12 +61,24 @@ type Template = {
sportsSeasonIds: string[]; 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 = [ function isBracktSport(sport: { slug: string }) {
"Basketball", "Football", "Baseball", "Hockey", return sport.slug === "brackt";
"Soccer", "Motorsport", "Golf", "Tennis", "Other", }
];
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 { function defaultRounds(count: number): number {
if (count <= 0) return 3; if (count <= 0) return 3;
@ -69,22 +86,6 @@ function defaultRounds(count: number): number {
return Math.max(count + 3, Math.ceil(count * pct)); 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 ───────────────────────────────────────────────────────────────────── // ─── Meta ─────────────────────────────────────────────────────────────────────
export function meta(): Route.MetaDescriptors { 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 session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null; const userId = session?.user.id ?? null;
await seedOrRepairBracktTemplate();
const templates = await findActiveSeasonTemplates(); const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons(); const allSportsSeasons = await findDraftableSportsSeasons();
@ -213,7 +216,9 @@ export async function action(args: Route.ActionArgs) {
leagueId: league.id, leagueId: league.id,
year: currentYear, year: currentYear,
status: "pre_draft", status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null, templateId: typeof templateId === "string" && templateId !== "" && templateId !== "customize"
? templateId
: null,
draftRounds: draftRoundsNum, draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null, draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
draftInitialTime: draftInitialTimeNum, draftInitialTime: draftInitialTimeNum,
@ -228,7 +233,7 @@ export async function action(args: Route.ActionArgs) {
await setCurrentSeason(league.id, season.id); await setCurrentSeason(league.id, season.id);
const selectedSports = formData.getAll("sportsSeasons"); const selectedSports = formData.getAll("sportsSeasons").map(String);
if (selectedSports.length > draftRoundsNum) { if (selectedSports.length > draftRoundsNum) {
return { 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 joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum); const teamNames = generateUniqueTeamNames(teamCountNum);
const creator = await findUserById(userId); const creator = await findUserById(userId);
@ -259,6 +255,16 @@ export async function action(args: Route.ActionArgs) {
await createManyTeams(teams); 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"); const userTimezone = formData.get("userTimezone");
if (typeof userTimezone === "string" && userTimezone) { if (typeof userTimezone === "string" && userTimezone) {
await updateUser(userId, { timezone: userTimezone }); await updateUser(userId, { timezone: userTimezone });
@ -401,20 +407,18 @@ function Step2Sports({
// When a named template is selected, only show that template's seasons // When a named template is selected, only show that template's seasons
// Always exclude seasons with fewer participants than the league's team count // Always exclude seasons with fewer participants than the league's team count
const activeTemplate = templates.find((t) => t.id === selectedTemplate); 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 const displaySeasons = selectedTemplate === "customize" || !activeTemplate
? eligibleSeasons ? eligibleSeasons
: eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id)); : eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id));
// Group sports by category const sortedDisplaySeasons = displaySeasons.toSorted((a, b) => {
const grouped: { category: string; seasons: SportSeason[] }[] = CATEGORY_ORDER const sportNameDiff = a.sport.name.localeCompare(b.sport.name);
.map((cat) => ({ if (sportNameDiff !== 0) return sportNameDiff;
category: cat, return b.year - a.year;
seasons: displaySeasons.filter((s) => getSportCategory(s.sport.name) === cat), });
}))
.filter(({ seasons }) =>
seasons.length > 0
);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -486,9 +490,14 @@ function Step2Sports({
return acc; return acc;
}, {}) }, {})
).toSorted((a, b) => a.name.localeCompare(b.name)).map((sport) => ( ).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} /> <SportIcon sport={sport} />
<span>{sport.name}</span> <span>{sport.name}</span>
{isBracktSport(sport) && <BracktInfoIcon />}
</li> </li>
))} ))}
</ul> </ul>
@ -540,44 +549,36 @@ function Step2Sports({
)} )}
{/* Full sport picker */} {/* Full sport picker */}
<div className="relative"> <div className="max-h-72 overflow-y-auto pr-1">
<div className="space-y-4 max-h-72 overflow-y-auto pr-1"> <div className="flex flex-col gap-1">
{grouped.map(({ category, seasons }) => ( {sortedDisplaySeasons.map((s) => {
<div key={category}> const selected = selectedSports.has(s.id);
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2"> return (
{category} <button
</p> key={s.id}
<div className="flex flex-col gap-1"> type="button"
{seasons.map((s) => { onClick={() => toggleSport(s.id)}
const selected = selectedSports.has(s.id); title={isBracktSport(s.sport) ? BRACKT_SPORT_TOOLTIP : undefined}
return ( className={cn(
<button "w-full flex items-center justify-between px-3 py-2 rounded-md text-sm border transition-colors",
key={s.id} selected
type="button" ? "bg-primary/10 border-primary text-primary font-medium"
onClick={() => toggleSport(s.id)} : "bg-muted border-border text-foreground hover:border-primary/50"
className={cn( )}
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm border transition-colors", >
selected <span className="flex items-center gap-2">
? "bg-primary/10 border-primary text-primary font-medium" <SportIcon sport={s.sport} />
: "bg-muted border-border text-foreground hover:border-primary/50" {s.sport.name} <span className="text-xs opacity-70">{s.year}</span>
)} {isBracktSport(s.sport) && <BracktInfoIcon />}
> </span>
<span className="flex items-center gap-2"> <span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span>
<SportIcon sport={s.sport} /> </button>
{s.sport.name} <span className="text-xs opacity-70">{s.year}</span> );
</span> })}
<span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span> {sortedDisplaySeasons.length === 0 && (
</button>
);
})}
</div>
</div>
))}
{grouped.length === 0 && (
<p className="text-sm text-muted-foreground">No sports seasons available.</p> <p className="text-sm text-muted-foreground">No sports seasons available.</p>
)} )}
</div> </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>
</div> </div>
)} )}

View file

@ -173,6 +173,32 @@ export default function Rules() {
</div> </div>
</section> </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 1st8th
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 */} {/* The Draft */}
<section> <section>
<h2 className="text-2xl font-semibold mb-1">The Draft</h2> <h2 className="text-2xl font-semibold mb-1">The Draft</h2>

View file

@ -29,6 +29,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { syncPrivateBracktParticipants } from "~/services/brackt.server";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Team Settings — ${data?.team?.name ?? "Team"} - Brackt` }]; return [{ title: `Team Settings — ${data?.team?.name ?? "Team"} - Brackt` }];
@ -100,6 +101,7 @@ export async function action(args: Route.ActionArgs) {
name: name.trim(), name: name.trim(),
logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined, logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined,
}); });
await syncPrivateBracktParticipants(team.seasonId);
const season = await findSeasonById(team.seasonId); const season = await findSeasonById(team.seasonId);
return redirect(`/leagues/${season?.leagueId}?updated=true`); return redirect(`/leagues/${season?.leagueId}?updated=true`);

View 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;
}

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

View 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",
};
});
}
}

View file

@ -26,6 +26,7 @@ import { WNBASimulator } from "./wnba-simulator";
import { WorldCupSimulator } from "./world-cup-simulator"; import { WorldCupSimulator } from "./world-cup-simulator";
import { NCAAFootballSimulator } from "./ncaa-football-simulator"; import { NCAAFootballSimulator } from "./ncaa-football-simulator";
import { LLWSSimulator } from "./llws-simulator"; import { LLWSSimulator } from "./llws-simulator";
import { BracktSimulator } from "./brackt-simulator";
export const SIMULATOR_TYPES = [ export const SIMULATOR_TYPES = [
"f1_standings", "f1_standings",
@ -48,6 +49,7 @@ export const SIMULATOR_TYPES = [
"cs2_major_qualifying_points", "cs2_major_qualifying_points",
"ncaa_football_bracket", "ncaa_football_bracket",
"llws_bracket", "llws_bracket",
"brackt",
] as const; ] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number]; export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -158,6 +160,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
}, },
create: () => new LLWSSimulator(), 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 { export function getSimulator(simulatorType: SimulatorType): Simulator {
@ -174,4 +183,3 @@ export function getSimulator(simulatorType: SimulatorType): Simulator {
export function getSimulatorInfo(simulatorType: SimulatorType): SimulatorInfo | null { export function getSimulatorInfo(simulatorType: SimulatorType): SimulatorInfo | null {
return REGISTRY[simulatorType]?.info ?? null; return REGISTRY[simulatorType]?.info ?? null;
} }

View file

@ -71,6 +71,7 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
cs2_major_qualifying_points: null, cs2_major_qualifying_points: null,
ncaa_football_bracket: null, ncaa_football_bracket: null,
llws_bracket: null, llws_bracket: null,
brackt: null,
}; };
/** /**

View file

@ -148,6 +148,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"cs2_major_qualifying_points", "cs2_major_qualifying_points",
"ncaa_football_bracket", "ncaa_football_bracket",
"llws_bracket", "llws_bracket",
"brackt",
]); ]);
export const tournamentStatusEnum = pgEnum("tournament_status", [ export const tournamentStatusEnum = pgEnum("tournament_status", [
@ -354,6 +355,9 @@ export const sportsSeasons = pgTable("sports_seasons", {
sportId: uuid("sport_id") sportId: uuid("sport_id")
.notNull() .notNull()
.references(() => sports.id, { onDelete: "cascade" }), .references(() => sports.id, { onDelete: "cascade" }),
fantasySeasonId: uuid("fantasy_season_id").references(() => seasons.id, {
onDelete: "cascade",
}),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
year: integer("year").notNull(), year: integer("year").notNull(),
startDate: date("start_date"), startDate: date("start_date"),
@ -904,6 +908,10 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
fields: [sportsSeasons.sportId], fields: [sportsSeasons.sportId],
references: [sports.id], references: [sports.id],
}), }),
fantasySeason: one(seasons, {
fields: [sportsSeasons.fantasySeasonId],
references: [seasons.id],
}),
participants: many(seasonParticipants), participants: many(seasonParticipants),
seasonTemplateSports: many(seasonTemplateSports), seasonTemplateSports: many(seasonTemplateSports),
seasonSports: many(seasonSports), seasonSports: many(seasonSports),
@ -999,6 +1007,7 @@ export const seasonsRelations = relations(seasons, ({ one, many }) => ({
}), }),
teams: many(teams), teams: many(teams),
seasonSports: many(seasonSports), seasonSports: many(seasonSports),
privateSportsSeasons: many(sportsSeasons),
})); }));
export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({ export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({
@ -1514,6 +1523,7 @@ export const auditActionEnum = pgEnum("audit_action", [
"force_manual_pick", "force_manual_pick",
"draft_pick_changed", "draft_pick_changed",
"time_bank_edited", "time_bank_edited",
"brackt_resolved",
]); ]);
export const commissionerAuditLog = pgTable("commissioner_audit_log", { export const commissionerAuditLog = pgTable("commissioner_audit_log", {

View 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.

View 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 $$;

File diff suppressed because it is too large Load diff

View file

@ -659,6 +659,13 @@
"when": 1777783789103, "when": 1777783789103,
"tag": "0093_ambiguous_hellcat", "tag": "0093_ambiguous_hellcat",
"breakpoints": true "breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1777920441672,
"tag": "0094_curved_nick_fury",
"breakpoints": true
} }
] ]
} }

View file

@ -39,6 +39,9 @@ interface ServerToClientEvents {
"participant-removed-from-queues": (data: { participantId: string }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void;
"queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void; "queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void;
"watchlist-updated": (data: { participantIds: string[] }) => void; "watchlist-updated": (data: { participantIds: string[] }) => void;
"brackt-evs-updated": (data: {
updates: Array<{ participantId: string; expectedValue: number; vorpValue: number }>;
}) => void;
"draft-state-sync": (data: { "draft-state-sync": (data: {
currentPickNumber: number; currentPickNumber: number;
isPaused: boolean; isPaused: boolean;