From 65593c82cf2eea0d378a490fdd837dc9790bf783 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 17:44:09 +0000 Subject: [PATCH] Fix recalculate-floors incorrectly eliminating play-in losers who still advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...sons.$id.events.$eventId.bracket.server.ts | 29 +++++++++---------- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 23 +-------------- server/socket.ts | 1 - 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index e52e709..f0f0fb3 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -52,7 +52,6 @@ import { logger } from "~/lib/logger"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; -import { getSocketIO } from "../../../server/socket"; export async function loader({ params }: Route.LoaderArgs) { 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). const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; 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( + template?.rounds.map((r) => [r.name, r.isScoring]) ?? [] + ); const sortedMatches = completed.toSorted((a, b) => { const ai = roundOrder.indexOf(a.round); @@ -606,12 +611,19 @@ export async function action({ request, params }: Route.ActionArgs) { }); 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( { round: match.round, winnerId: match.winnerId ?? "", loserId: match.loserId ?? "", - isScoring: match.isScoring ?? true, + isScoring, sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, 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. 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)` }; } catch (error) { logger.error("Error recalculating floors:", error); diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 95121ce..9746f77 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -1,6 +1,4 @@ -import { Link, useRevalidator } from "react-router"; -import { useEffect } from "react"; -import { io } from "socket.io-client"; +import { Link } from "react-router"; import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId"; import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server"; @@ -71,25 +69,6 @@ export default function SportSeasonDetail({ groupStandings, } = 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 hasStandings = regularSeasonStandings && regularSeasonStandings.length > 0; const showOtLosses = hasStandings && regularSeasonStandings.some((s) => s.otLosses !== null); diff --git a/server/socket.ts b/server/socket.ts index b2d6e01..8bdea68 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -37,7 +37,6 @@ interface ServerToClientEvents { "draft-resumed": (data: { seasonId: string; paused: boolean }) => 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; - "standings-updated": (data: { sportsSeasonId: string }) => void; "draft-state-sync": (data: { currentPickNumber: number; isPaused: boolean;