claude/shared-tournaments-events-ux-6z5jkt #125

Merged
chrisp merged 2 commits from claude/shared-tournaments-events-ux-6z5jkt into main 2026-07-02 18:30:20 +00:00
7 changed files with 380 additions and 21 deletions
Showing only changes of commit 7f7ea4e29d - Show all commits

View file

@ -63,4 +63,101 @@ 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("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);
});
});
});

View file

@ -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();
});
});

View file

@ -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<typeof database>
) {
providedDb?: ReturnType<typeof database>,
opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
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,41 @@ 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 (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) {
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,
};
}
/**

View file

@ -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<typeof database>
): Promise<void> {
const db = providedDb || database();
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
}

View file

@ -8,6 +8,7 @@ import {
deleteScoringEvent,
bulkCreateScoringEvents,
ensurePrimaryEvent,
getSportsSeasonsByTournament,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
@ -59,11 +60,39 @@ 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<string, { name: string; windowCount: number }>();
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),
]);
sharedInfo.set(tid, {
name: tournament?.name ?? "shared tournament",
windowCount: windows.length,
});
})
);
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 +244,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" };

View file

@ -305,7 +305,11 @@ export default function SportsSeasonEvents({
</p>
) : (
<div className="space-y-3">
{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 (
<Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This will also delete all results for this event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription asChild>
{!isLinked ? (
<span>
This will also delete all results for this event. This action cannot be undone.
</span>
) : hasOtherWindows ? (
<span>
This event is part of the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
<span className="font-medium">
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
</span>
. Deleting removes it from <span className="font-medium">this season only</span> the
tournament and the other seasons keep their data.
{event.isPrimary && (
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
)}
</span>
) : (
<span>
This is the only season linked to the shared tournament{" "}
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
event and its results. This action cannot be undone.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{isLastWindow && (
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
<input
type="checkbox"
name="deleteTournament"
value="1"
className="mt-0.5 h-4 w-4 rounded border-input"
/>
<span>
Also delete the shared tournament and its recorded results
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogFooter>
</Form>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
);
})}
</div>
)}
</CardContent>

View file

@ -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<typeof action>();
const primaryFetcher = useFetcher<typeof action>();
const unlinkFetcher = useFetcher<typeof action>();
// 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}
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={unlinkFetcher.state !== "idle"}
>
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<span>
This deletes that season's scoring event (and its results) but keeps{" "}
<span className="font-medium">{tournament.name}</span> 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.</>
)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<unlinkFetcher.Form method="post">
<input type="hidden" name="intent" value="unlink-window" />
<input type="hidden" name="eventId" value={link.id} />
<AlertDialogAction
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</unlinkFetcher.Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}