diff --git a/app/models/__tests__/scoring-event-delete.test.ts b/app/models/__tests__/scoring-event-delete.test.ts
index c73741c..81e98f7 100644
--- a/app/models/__tests__/scoring-event-delete.test.ts
+++ b/app/models/__tests__/scoring-event-delete.test.ts
@@ -63,4 +63,119 @@ describe("deleteScoringEvent", () => {
])
);
});
+
+ describe("shared tournament handling", () => {
+ // Builds a mock db for a non-qualifying schedule_event linked to a tournament,
+ // so the delete skips the QP/league branches and exercises only the
+ // shared-tournament bookkeeping.
+ function makeSharedDb({
+ deletedEvent,
+ remaining,
+ siblingTournamentId = "tournament-1",
+ }: {
+ deletedEvent: { tournamentId: string | null; isPrimary: boolean };
+ remaining: Array<{ id: string; isPrimary: boolean }>;
+ siblingTournamentId?: string;
+ }) {
+ const findFirst = vi
+ .fn()
+ // 1st call: the event being deleted
+ .mockResolvedValueOnce({
+ id: "event-1",
+ sportsSeasonId: "sports-season-1",
+ eventType: "schedule_event",
+ isQualifyingEvent: false,
+ ...deletedEvent,
+ })
+ // subsequent calls: setPrimaryEvent looking up the promoted event
+ .mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
+
+ const deleteWhere = vi.fn().mockResolvedValue(undefined);
+ const updateWhere = vi.fn().mockResolvedValue(undefined);
+ const db = {
+ query: {
+ scoringEvents: {
+ findFirst,
+ findMany: vi.fn().mockResolvedValue(remaining),
+ },
+ },
+ transaction: vi.fn(async (callback) => callback(db)),
+ delete: vi.fn(() => ({ where: deleteWhere })),
+ update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
+ } as any;
+ return { db, deleteWhere };
+ }
+
+ it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
+ const { db } = makeSharedDb({
+ deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
+ remaining: [{ id: "event-2", isPrimary: true }],
+ });
+
+ const result = await deleteScoringEvent("event-1", db);
+
+ expect(result.remainingWindows).toBe(1);
+ expect(result.promotedPrimaryId).toBeNull();
+ 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 },
+ // findMany is ordered asc(createdAt); first is the earliest.
+ remaining: [
+ { id: "event-2", isPrimary: false },
+ { id: "event-3", isPrimary: false },
+ ],
+ });
+
+ const result = await deleteScoringEvent("event-1", db);
+
+ expect(result.promotedPrimaryId).toBe("event-2");
+ expect(result.remainingWindows).toBe(2);
+ });
+
+ it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
+ const { db } = makeSharedDb({
+ deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
+ remaining: [],
+ });
+
+ const result = await deleteScoringEvent("event-1", db);
+
+ expect(result.remainingWindows).toBe(0);
+ expect(result.deletedTournament).toBe(false);
+ });
+
+ it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
+ const { db } = makeSharedDb({
+ deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
+ remaining: [],
+ });
+
+ const result = await deleteScoringEvent("event-1", db, {
+ deleteOrphanTournament: true,
+ });
+
+ expect(result.deletedTournament).toBe(true);
+ });
+ });
});
diff --git a/app/models/__tests__/tournament.test.ts b/app/models/__tests__/tournament.test.ts
index e7f0406..6e5e4f9 100644
--- a/app/models/__tests__/tournament.test.ts
+++ b/app/models/__tests__/tournament.test.ts
@@ -11,6 +11,7 @@ import {
findTournamentBySportNameYear,
upsertTournament,
updateTournamentStatus,
+ deleteTournament,
} from "../tournament";
import { database } from "~/database/context";
@@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress");
});
});
+
+describe("deleteTournament", () => {
+ it("deletes the tournament row by id", async () => {
+ const where = vi.fn().mockResolvedValue(undefined);
+ const db = { delete: vi.fn().mockReturnValue({ where }) };
+ vi.mocked(database).mockReturnValue(db as never);
+
+ await deleteTournament(TOURNAMENT_ID);
+
+ expect(db.delete).toHaveBeenCalledTimes(1);
+ expect(where).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses a provided db when passed (transaction)", async () => {
+ const where = vi.fn().mockResolvedValue(undefined);
+ const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
+
+ await deleteTournament(TOURNAMENT_ID, providedDb as never);
+
+ expect(providedDb.delete).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(database)).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts
index dac8156..fd6de2d 100644
--- a/app/models/scoring-event.ts
+++ b/app/models/scoring-event.ts
@@ -5,6 +5,7 @@ import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
+import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
@@ -201,17 +202,46 @@ export async function completeScoringEvent(
* sports season (placements are stored there with no FK back to the event) and
* recalculates league standings.
*/
+export interface DeleteScoringEventOptions {
+ /**
+ * When this was the last window linked to its shared tournament, also delete
+ * the now-orphaned canonical tournament (and its results). No-op when other
+ * windows remain. Off by default so deleting one season's event never touches
+ * the shared tournament unless explicitly requested.
+ */
+ deleteOrphanTournament?: boolean;
+}
+
+export interface DeleteScoringEventResult {
+ /** The shared tournament this event was linked to, if any. */
+ tournamentId: string | null;
+ /** How many other windows still link to that tournament after the delete. */
+ remainingWindows: number;
+ /** Event promoted to primary because the deleted event was the primary window. */
+ promotedPrimaryId: string | null;
+ /** Whether the orphaned canonical tournament was deleted. */
+ deletedTournament: boolean;
+}
+
export async function deleteScoringEvent(
eventId: string,
- providedDb?: ReturnType
-) {
+ providedDb?: ReturnType,
+ opts?: DeleteScoringEventOptions
+): Promise {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
- if (!event) return;
+ if (!event) {
+ return {
+ tournamentId: null,
+ remainingWindows: 0,
+ promotedPrimaryId: null,
+ deletedTournament: false,
+ };
+ }
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
@@ -260,6 +290,43 @@ export async function deleteScoringEvent(
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
+
+ // Shared-tournament bookkeeping: keep the primary window valid and optionally
+ // clean up a canonical tournament left with no windows.
+ let remainingWindows = 0;
+ let promotedPrimaryId: string | null = null;
+ let deletedTournament = false;
+
+ if (event.tournamentId) {
+ const remaining = await db.query.scoringEvents.findMany({
+ where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
+ orderBy: asc(schema.scoringEvents.createdAt),
+ });
+ remainingWindows = remaining.length;
+
+ if (remaining.length > 0) {
+ // 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;
+ }
+ } else if (opts?.deleteOrphanTournament) {
+ await deleteTournament(event.tournamentId, db);
+ deletedTournament = true;
+ }
+ }
+
+ return {
+ tournamentId: event.tournamentId,
+ remainingWindows,
+ promotedPrimaryId,
+ deletedTournament,
+ };
}
/**
@@ -1061,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/models/tournament.ts b/app/models/tournament.ts
index 2bf7c51..fadd706 100644
--- a/app/models/tournament.ts
+++ b/app/models/tournament.ts
@@ -136,3 +136,17 @@ export async function updateTournamentStatus(
.returning();
return tournament;
}
+
+/**
+ * Delete a canonical tournament. The DB cascades remove its tournament_results;
+ * any surviving scoring_events have their tournamentId set to NULL (onDelete:
+ * "set null"). Callers should only use this once the last linked window has been
+ * removed, so no scoring events are left pointing at it.
+ */
+export async function deleteTournament(
+ id: string,
+ providedDb?: ReturnType
+): Promise {
+ const db = providedDb || database();
+ await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
+}
diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts
index d31b256..90b4968 100644
--- a/app/routes/admin.sports-seasons.$id.events.server.ts
+++ b/app/routes/admin.sports-seasons.$id.events.server.ts
@@ -8,6 +8,7 @@ import {
deleteScoringEvent,
bulkCreateScoringEvents,
ensurePrimaryEvent,
+ countWindowsByTournament,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
@@ -59,11 +60,38 @@ export async function loader({ params }: Route.LoaderArgs) {
);
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
+ // Shared-tournament context for the delete confirmation dialog: for each event
+ // linked to a canonical tournament, how many OTHER seasons ("windows") also use
+ // it, and the tournament's name. Lets the UI explain exactly what a delete does.
+ const sharedInfo = new Map();
+ await Promise.all(
+ [...linkedTournamentIds].map(async (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,
+ });
+ })
+ );
+
+ const eventsWithSharing = events.map((e) => {
+ const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
+ return {
+ ...e,
+ tournamentName: info?.name ?? null,
+ otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
+ };
+ });
+
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
},
- events,
+ events: eventsWithSharing,
qpStandings,
scoringRules,
availableTournaments,
@@ -215,8 +243,19 @@ export async function action({ request, params }: Route.ActionArgs) {
}
try {
- await deleteScoringEvent(eventId);
- return { success: "Event deleted successfully" };
+ const result = await deleteScoringEvent(eventId, undefined, {
+ deleteOrphanTournament: formData.get("deleteTournament") === "1",
+ });
+ let success = "Event deleted";
+ if (result.deletedTournament) {
+ success = "Event deleted and its shared tournament removed";
+ } else if (result.tournamentId && result.remainingWindows > 0) {
+ success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
+ if (result.promotedPrimaryId) {
+ success += "; another season was promoted to primary";
+ }
+ }
+ return { success };
} catch (error) {
logger.error("Error deleting event:", error);
return { error: "Failed to delete event" };
diff --git a/app/routes/admin.sports-seasons.$id.events.tsx b/app/routes/admin.sports-seasons.$id.events.tsx
index fd13261..f18b0e6 100644
--- a/app/routes/admin.sports-seasons.$id.events.tsx
+++ b/app/routes/admin.sports-seasons.$id.events.tsx
@@ -305,7 +305,11 @@ export default function SportsSeasonEvents({
) : (
- {events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
+ {events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
+ const isLinked = !!event.tournamentId;
+ const hasOtherWindows = event.otherWindowCount > 0;
+ const isLastWindow = isLinked && !hasOtherWindows;
+ return (
@@ -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
+
+
+
+
+
+ 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
+
+
+
+
+
))}