From 95acc6fcbacba29ef21e8f9c091b4619a2714a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 21:40:08 +0000 Subject: [PATCH] Add a Re-seed Wildcard Winners button to the bracket admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairing a bracket advanced before the re-seeding rule needed a script and a shell. Add the same repair as an admin action on the event's bracket page, shown for afl_10 brackets: it runs reseedAflEliminationFinals and reports which team each Elimination Final now hosts, or says the pairings were already right. Only the qualifier slots move, so no scoring runs and nothing is announced — a test asserts the action calls neither the scoring path nor Discord. A bracket whose Elimination Final has already been played still refuses, with the model's message surfaced to the admin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK --- ....sports-seasons.bracket.reseed-afl.test.ts | 133 ++++++++++++++++++ ...sons.$id.events.$eventId.bracket.server.ts | 43 ++++++ ...ts-seasons.$id.events.$eventId.bracket.tsx | 25 ++++ scripts/fix-afl-wildcard-reseed.ts | 4 + 4 files changed, 205 insertions(+) create mode 100644 app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts new file mode 100644 index 0000000..1cda784 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.reseed-afl.test.ts @@ -0,0 +1,133 @@ +/** + * The Re-seed Wildcard Winners admin action. + * + * Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every + * result, so this action exists for brackets advanced before that rule: their winners sit + * in the wrong Elimination Finals and nothing re-runs advancement, because a completed + * match cannot be re-submitted from the UI. + * + * It moves qualifier slots only — no scoring runs, so nothing reaches Discord. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { reseedAflEliminationFinals } from "~/models/playoff-match"; +import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; +import { getScoringEventById } from "~/models/scoring-event"; +import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator"; +import { sendDiscordWebhook } from "~/services/discord"; +import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server"; + +vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) })); +vi.mock("~/models/scoring-event", async (importOriginal) => ({ + ...(await importOriginal()), + getScoringEventById: vi.fn(), + isReadOnlySibling: vi.fn(() => false), +})); +vi.mock("~/models/playoff-match", async (importOriginal) => ({ + ...(await importOriginal()), + reseedAflEliminationFinals: vi.fn(), +})); +vi.mock("~/models/season-participant", async (importOriginal) => ({ + ...(await importOriginal()), + findParticipantsBySportsSeasonId: vi.fn(), +})); +vi.mock("~/models/scoring-calculator", async (importOriginal) => ({ + ...(await importOriginal()), + processMatchResult: vi.fn(), + recalculateAffectedLeagues: vi.fn(), +})); +vi.mock("~/services/discord", async (importOriginal) => ({ + ...(await importOriginal()), + sendDiscordWebhook: vi.fn(), +})); + +const params = { id: "season-1", eventId: "event-1" }; + +const EVENT = { + id: "event-1", + name: "AFL Finals", + sportsSeasonId: "season-1", + isQualifyingEvent: false, + bracketTemplateId: "afl_10", +}; + +function request() { + const body = new FormData(); + body.set("intent", "reseed-afl-wildcard"); + return new Request("http://localhost/bracket", { method: "POST", body }); +} + +const run = () => action({ request: request(), params } as never); + +describe("reseed-afl-wildcard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never); + vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([ + { id: "carlton", name: "Carlton Blues" }, + { id: "bulldogs", name: "Western Bulldogs" }, + ] as never); + }); + + it("names the teams that moved", async () => { + vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ + vacated: [1, 2], + filled: [ + { matchNumber: 2, participantId: "bulldogs" }, + { matchNumber: 1, participantId: "carlton" }, + ], + }); + + const result = await run(); + + expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1"); + expect(result).toEqual({ + success: + "Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " + + "match 2 now hosts Western Bulldogs.", + }); + }); + + it("says so when the pairings are already right", async () => { + vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] }); + + expect(await run()).toEqual({ + success: "Elimination Finals already match the Wildcard results — nothing to re-seed.", + }); + }); + + it("scores nothing and announces nothing", async () => { + vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ + vacated: [1, 2], + filled: [{ matchNumber: 1, participantId: "carlton" }], + }); + + await run(); + + expect(processMatchResult).not.toHaveBeenCalled(); + expect(recalculateAffectedLeagues).not.toHaveBeenCalled(); + expect(sendDiscordWebhook).not.toHaveBeenCalled(); + }); + + it("refuses a bracket that is not an AFL finals bracket", async () => { + vi.mocked(getScoringEventById).mockResolvedValue({ + ...EVENT, + bracketTemplateId: "nfl_14", + } as never); + + expect(await run()).toEqual({ + error: "This action only applies to AFL finals brackets", + }); + expect(reseedAflEliminationFinals).not.toHaveBeenCalled(); + }); + + it("surfaces a refusal to re-seed a game that has been played", async () => { + vi.mocked(reseedAflEliminationFinals).mockRejectedValue( + new Error("Elimination Finals match 1 already has a recorded result") + ); + + expect(await run()).toEqual({ + error: "Elimination Finals match 1 already has a recorded result", + }); + }); +}); 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 bb8c04e..63943bf 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 @@ -16,6 +16,7 @@ import { findPlayoffMatchById, assignParticipantsToKnockout, doesLoserAdvance, + reseedAflEliminationFinals, } from "~/models/playoff-match"; import { createGame, @@ -866,6 +867,48 @@ export async function action({ request, params }: Route.ActionArgs) { } } + // Re-seed the AFL Wildcard winners into the Elimination Finals they belong in. + // Advancement does this on every Wildcard result, so this is only needed for a + // bracket advanced before that rule existed: the winners sit in the wrong games and + // no admin action re-runs advancement (a completed match cannot be re-submitted). + if (intent === "reseed-afl-wildcard") { + try { + const event = await getScoringEventById(params.eventId); + if (!event) return { error: "Event not found" }; + if (event.bracketTemplateId !== "afl_10") { + return { error: "This action only applies to AFL finals brackets" }; + } + + const participants = await findParticipantsBySportsSeasonId(params.id); + const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id; + + const reseed = await reseedAflEliminationFinals(params.eventId); + if (reseed.vacated.length === 0 && reseed.filled.length === 0) { + return { + success: + "Elimination Finals already match the Wildcard results — nothing to re-seed.", + }; + } + + // Only the qualifier slots move, so there is nothing to re-score: no placement, + // score or elimination changes, and so nothing to announce. + const moves = reseed.filled + .toSorted((a, b) => a.matchNumber - b.matchNumber) + .map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`) + .join(", "); + + return { + success: `Re-seeded the Elimination Finals: ${moves}.`, + }; + } catch (error) { + logger.error("Error re-seeding AFL Wildcard winners:", error); + return { + error: + error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals", + }; + } + } + if (intent === "reprocess-bracket") { try { const event = await getScoringEventById(params.eventId); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 6442f20..92d5b96 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -613,6 +613,31 @@ export default function EventBracket({ )} + {/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on + every Wildcard result, so this is only for a bracket advanced before that + rule existed — a completed match cannot be re-submitted to re-run it. */} + {event.bracketTemplateId === "afl_10" && matches.length > 0 && ( + + + Re-seed Wildcard Winners + + Pair the Elimination Finals by ladder position: 5th hosts the + lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves + the qualifier slots — no results, scores or placements change, and + nothing is announced. Does nothing if the pairings are already right. + + + +
+ + +
+
+
+ )} + {/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else can rewrite a match's participants, so a wrong seeding has to be torn down and rebuilt via the setup form below, which reappears once this runs. */} diff --git a/scripts/fix-afl-wildcard-reseed.ts b/scripts/fix-afl-wildcard-reseed.ts index 2d054a9..da30392 100644 --- a/scripts/fix-afl-wildcard-reseed.ts +++ b/scripts/fix-afl-wildcard-reseed.ts @@ -14,6 +14,10 @@ * touched, and nothing else in the bracket is written. A bracket that is already correct * is left alone. * + * Admin → the event's bracket has a "Re-seed Wildcard Winners" button that does exactly + * this for one event; use this script to sweep every afl_10 event, or where the UI is not + * to hand. + * * If an Elimination Final has already been played, its qualifier cannot be moved without * rewriting who contested a recorded result; the script reports that event and skips it. * Clear and regenerate that bracket in Admin instead, then Reprocess Bracket.