Fix recalculate-floors incorrectly eliminating play-in losers who still advance
The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB
This commit is contained in:
parent
90a4039cba
commit
65593c82cf
3 changed files with 15 additions and 38 deletions
|
|
@ -52,7 +52,6 @@ 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 { getSocketIO } from "../../../server/socket";
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -598,6 +597,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Replay each completed match in bracket order (earlier rounds first).
|
// Replay each completed match in bracket order (earlier rounds first).
|
||||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||||
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
||||||
|
// Build a round→isScoring map from the template so we use the template as the
|
||||||
|
// source of truth rather than the DB column (which defaults to true and may be
|
||||||
|
// wrong for brackets created before the isScoring column was added).
|
||||||
|
const templateRoundIsScoring = new Map<string, boolean>(
|
||||||
|
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
const sortedMatches = completed.toSorted((a, b) => {
|
const sortedMatches = completed.toSorted((a, b) => {
|
||||||
const ai = roundOrder.indexOf(a.round);
|
const ai = roundOrder.indexOf(a.round);
|
||||||
|
|
@ -606,12 +611,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const match of sortedMatches) {
|
for (const match of sortedMatches) {
|
||||||
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||||
|
// defaults to true, so legacy play-in rows that predate the column are
|
||||||
|
// incorrectly marked as scoring and would assign finalPosition=0 to losers
|
||||||
|
// who still have a second game (e.g. NBA 7v8 play-in loser → Round 2).
|
||||||
|
const isScoring = templateRoundIsScoring.has(match.round)
|
||||||
|
? templateRoundIsScoring.get(match.round)!
|
||||||
|
: (match.isScoring ?? true);
|
||||||
await processMatchResult(
|
await processMatchResult(
|
||||||
{
|
{
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId: match.winnerId ?? "",
|
winnerId: match.winnerId ?? "",
|
||||||
loserId: match.loserId ?? "",
|
loserId: match.loserId ?? "",
|
||||||
isScoring: match.isScoring ?? true,
|
isScoring,
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
|
|
@ -623,19 +635,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// skipDiscord: recalculate-floors is a data-correction tool, not a result announcement.
|
// skipDiscord: recalculate-floors is a data-correction tool, not a result announcement.
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||||
|
|
||||||
// Notify connected league pages to refresh their elimination/standings data.
|
|
||||||
try {
|
|
||||||
const seasonSports = await db.query.seasonSports.findMany({
|
|
||||||
where: eq(schema.seasonSports.sportsSeasonId, event.sportsSeasonId),
|
|
||||||
});
|
|
||||||
const io = getSocketIO();
|
|
||||||
for (const ss of seasonSports) {
|
|
||||||
io.to(`draft-${ss.seasonId}`).emit("standings-updated", { sportsSeasonId: event.sportsSeasonId });
|
|
||||||
}
|
|
||||||
} catch (socketError) {
|
|
||||||
logger.warn("[recalculate-floors] Could not emit standings-updated:", socketError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error recalculating floors:", error);
|
logger.error("Error recalculating floors:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { Link, useRevalidator } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
|
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
|
|
@ -71,25 +69,6 @@ export default function SportSeasonDetail({
|
||||||
groupStandings,
|
groupStandings,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
// Revalidate when the admin updates standings (e.g. recalculate-floors).
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = io({ path: "/socket.io", transports: ["websocket", "polling"] });
|
|
||||||
socket.on("connect", () => {
|
|
||||||
socket.emit("join-draft", season.id);
|
|
||||||
});
|
|
||||||
socket.on("standings-updated", (data: { sportsSeasonId: string }) => {
|
|
||||||
if (data.sportsSeasonId === sportsSeason.id) {
|
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
socket.emit("leave-draft", season.id);
|
|
||||||
socket.disconnect();
|
|
||||||
};
|
|
||||||
}, [season.id, sportsSeason.id, revalidate]);
|
|
||||||
|
|
||||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||||
const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
|
const hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0;
|
||||||
const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
|
const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null);
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ interface ServerToClientEvents {
|
||||||
"draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
|
"draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
|
||||||
"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;
|
||||||
"standings-updated": (data: { sportsSeasonId: string }) => 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