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;
|
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]);
|
||||||
|
|
|
||||||
|
|
@ -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, " ");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 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 */}
|
{/* 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>
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue