@@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
-
- Delete "{event.name}"?
-
- This will also delete all results for this event. This action cannot be undone.
-
-
-
- Cancel
-
-
+
+
- ))}
+ );
+ })}
)}
diff --git a/app/routes/admin.tournaments.$id.tsx b/app/routes/admin.tournaments.$id.tsx
index a7fadf9..5b1c8b2 100644
--- a/app/routes/admin.tournaments.$id.tsx
+++ b/app/routes/admin.tournaments.$id.tsx
@@ -21,6 +21,7 @@ import {
getSportsSeasonsByTournament,
getPrimaryEventForTournament,
setPrimaryEvent,
+ deleteScoringEvent,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
@@ -30,6 +31,17 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
@@ -214,6 +226,26 @@ export async function action(args: Route.ActionArgs) {
}
}
+ if (intent === "unlink-window") {
+ const eventId = formData.get("eventId");
+ if (typeof eventId !== "string" || !eventId) {
+ return { success: false as const, error: "Event ID is required", syncReport: null };
+ }
+ try {
+ // Remove that season's scoring event. The tournament stays (we're on its
+ // page); if this was the primary window, another is auto-promoted.
+ await deleteScoringEvent(eventId);
+ return { success: true as const, error: null, syncReport: null };
+ } catch (error) {
+ logger.error("unlink-window failed:", error);
+ return {
+ success: false as const,
+ error: error instanceof Error ? error.message : "Failed to remove season",
+ syncReport: null,
+ };
+ }
+ }
+
return {
success: false as const,
error: "Invalid intent",
@@ -236,6 +268,7 @@ export default function AdminTournamentDetail({
} = loaderData;
const retryFetcher = useFetcher();
const primaryFetcher = useFetcher();
+ const unlinkFetcher = useFetcher();
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref =
@@ -419,6 +452,51 @@ export default function AdminTournamentDetail({
>
{link.sportsSeason.status}
+
+
+
+
+
+
+
+ Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
+
+
+
+ This deletes that season's scoring event (and its results) but keeps{" "}
+ {tournament.name} and the other linked seasons.
+ {link.isPrimary && linkedSportsSeasons.length > 1 && (
+ <> Because this is the primary scoring window, another season will be promoted to primary automatically.>
+ )}
+ {linkedSportsSeasons.length === 1 && (
+ <> This is the only linked season — the tournament will remain but have no windows until you link one again.>
+ )}
+
+
+
+
+ Cancel
+
+
+
+
+ Remove
+
+
+
+
+
))}
--
2.45.3
From ec89e3eb8918960eb6e38573b75b2034b16b8939 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 2 Jul 2026 05:27:14 +0000
Subject: [PATCH 2/2] Address review: don't auto-promote primary for golf;
lighten window count
- Auto-heal now only promotes a new primary when the deleted event was
itself the primary window. Golf-style shared majors intentionally have
no primary (scored on the canonical tournament page), so deleting one
of their windows no longer flips a sibling into a primary and changes
its scoring/guard behavior. Adds a regression test for that case.
- Replace the relation-heavy getSportsSeasonsByTournament + double
Array.find in the events loader with a new countWindowsByTournament
helper (count(distinct sports_season_id)) and a single cached lookup.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
---
.../__tests__/scoring-event-delete.test.ts | 18 +++++++++++
app/models/scoring-event.ts | 30 +++++++++++++++----
.../admin.sports-seasons.$id.events.server.ts | 13 ++++----
3 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/app/models/__tests__/scoring-event-delete.test.ts b/app/models/__tests__/scoring-event-delete.test.ts
index 6e29e84..81e98f7 100644
--- a/app/models/__tests__/scoring-event-delete.test.ts
+++ b/app/models/__tests__/scoring-event-delete.test.ts
@@ -119,6 +119,24 @@ describe("deleteScoringEvent", () => {
expect(result.deletedTournament).toBe(false);
});
+ it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
+ // Golf-style shared majors intentionally have no primary window — every
+ // linked event is isPrimary=false. Deleting one must not flip a sibling
+ // into a primary, which would change its scoring/guard behavior.
+ const { db } = makeSharedDb({
+ deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
+ remaining: [
+ { id: "event-2", isPrimary: false },
+ { id: "event-3", isPrimary: false },
+ ],
+ });
+
+ const result = await deleteScoringEvent("event-1", db);
+
+ expect(result.promotedPrimaryId).toBeNull();
+ expect(result.remainingWindows).toBe(2);
+ });
+
it("promotes the earliest remaining window when the primary window is deleted", async () => {
const { db } = makeSharedDb({
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts
index 5ba0811..fd6de2d 100644
--- a/app/models/scoring-event.ts
+++ b/app/models/scoring-event.ts
@@ -305,11 +305,13 @@ export async function deleteScoringEvent(
remainingWindows = remaining.length;
if (remaining.length > 0) {
- // Auto-heal: if the deleted event was the primary window (or no sibling is
- // flagged primary anymore), promote the earliest remaining window so the
- // shared major stays scorable and fan-out stays deterministic.
- const hasPrimary = remaining.some((e) => e.isPrimary);
- if (event.isPrimary || !hasPrimary) {
+ // Auto-heal: if the deleted event was the primary window, promote the
+ // earliest remaining window so the shared major stays scorable and
+ // fan-out stays deterministic. Only heal when we actually removed the
+ // primary — golf-style majors intentionally have no primary (scored on the
+ // canonical tournament page), and event.isPrimary is false for them, so
+ // they are left untouched.
+ if (event.isPrimary) {
await setPrimaryEvent(remaining[0].id, db);
promotedPrimaryId = remaining[0].id;
}
@@ -1126,6 +1128,24 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
});
}
+/**
+ * Count the distinct sports-season windows linked to a tournament. Cheaper than
+ * getSportsSeasonsByTournament when only the count is needed (no relations).
+ */
+export async function countWindowsByTournament(
+ tournamentId: string,
+ providedDb?: ReturnType
+): Promise {
+ const db = providedDb || database();
+ const [row] = await db
+ .select({
+ count: sql`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
+ })
+ .from(schema.scoringEvents)
+ .where(eq(schema.scoringEvents.tournamentId, tournamentId));
+ return row?.count ?? 0;
+}
+
/**
* The primary scoring event for a tournament — the single window where the admin
* builds the bracket/stages and scoring happens; its results fan out to siblings.
diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts
index b284421..90b4968 100644
--- a/app/routes/admin.sports-seasons.$id.events.server.ts
+++ b/app/routes/admin.sports-seasons.$id.events.server.ts
@@ -8,7 +8,7 @@ import {
deleteScoringEvent,
bulkCreateScoringEvents,
ensurePrimaryEvent,
- getSportsSeasonsByTournament,
+ countWindowsByTournament,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
@@ -66,15 +66,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const sharedInfo = new Map();
await Promise.all(
[...linkedTournamentIds].map(async (tid) => {
- const [tournament, windows] = await Promise.all([
- allTournamentsForSport.find((t) => t.id === tid)
- ? Promise.resolve(allTournamentsForSport.find((t) => t.id === tid)!)
- : getTournamentById(tid),
- getSportsSeasonsByTournament(tid),
+ const cached = allTournamentsForSport.find((t) => t.id === tid);
+ const [tournament, windowCount] = await Promise.all([
+ cached ?? getTournamentById(tid),
+ countWindowsByTournament(tid),
]);
sharedInfo.set(tid, {
name: tournament?.name ?? "shared tournament",
- windowCount: windows.length,
+ windowCount,
});
})
);
--
2.45.3