Compare commits

...

3 commits

Author SHA1 Message Date
0c100aa834 Merge pull request 'claude/shared-tournaments-events-ux-6z5jkt' (#125) from claude/shared-tournaments-events-ux-6z5jkt into main
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reviewed-on: #125
2026-07-02 18:30:18 +00:00
Claude
ec89e3eb89
Address review: don't auto-promote primary for golf; lighten window count
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m26s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:27:14 +00:00
Claude
7f7ea4e29d
Improve shared-tournament/event management UX
Deleting an event from a season only affects that season's scoring
event; the shared canonical tournament and the other linked seasons
survive. The old flow never communicated this and offered no way to
manage the relationship, so it felt like deleting an event might wipe
the whole shared tournament.

- deleteScoringEvent is now shared-tournament-aware: it auto-heals the
  primary window (promotes the earliest remaining window when the
  primary is removed) and optionally deletes the canonical tournament
  when the last linked window is removed. Returns a result describing
  what happened.
- Add deleteTournament model helper.
- Events page: delete confirmation now explains exactly what a delete
  does (removed from this season only vs. last window), with an opt-in
  checkbox to also remove the orphaned shared tournament.
- Tournament page: add a per-window "Remove" control on the Linked
  Sports Seasons card to unlink a season, reusing the auto-heal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:17:37 +00:00
7 changed files with 417 additions and 21 deletions

View file

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

View file

@ -11,6 +11,7 @@ import {
findTournamentBySportNameYear, findTournamentBySportNameYear,
upsertTournament, upsertTournament,
updateTournamentStatus, updateTournamentStatus,
deleteTournament,
} from "../tournament"; } from "../tournament";
import { database } from "~/database/context"; import { database } from "~/database/context";
@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
expect(result.status).toBe("in_progress"); 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 { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant"; import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types"; import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } 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 * sports season (placements are stored there with no FK back to the event) and
* recalculates league standings. * 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( export async function deleteScoringEvent(
eventId: string, eventId: string,
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>,
) { opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
const db = providedDb || database(); const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({ const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId), 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 // For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event). // 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); 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<typeof database>
): Promise<number> {
const db = providedDb || database();
const [row] = await db
.select({
count: sql<number>`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 * 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. * builds the bracket/stages and scoring happens; its results fan out to siblings.

View file

@ -136,3 +136,17 @@ export async function updateTournamentStatus(
.returning(); .returning();
return tournament; 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, deleteScoringEvent,
bulkCreateScoringEvents, bulkCreateScoringEvents,
ensurePrimaryEvent, ensurePrimaryEvent,
countWindowsByTournament,
type CreateScoringEventData, type CreateScoringEventData,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils"; 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)); 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 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 { return {
sportsSeason: sportsSeason as typeof sportsSeason & { sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string }; sport: { id: string; name: string; type: string; slug: string };
}, },
events, events: eventsWithSharing,
qpStandings, qpStandings,
scoringRules, scoringRules,
availableTournaments, availableTournaments,
@ -215,8 +243,19 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
try { try {
await deleteScoringEvent(eventId); const result = await deleteScoringEvent(eventId, undefined, {
return { success: "Event deleted successfully" }; 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) { } catch (error) {
logger.error("Error deleting event:", error); logger.error("Error deleting event:", error);
return { error: "Failed to delete event" }; return { error: "Failed to delete event" };

View file

@ -305,7 +305,11 @@ export default function SportsSeasonEvents({
</p> </p>
) : ( ) : (
<div className="space-y-3"> <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"> <Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <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"> <Form method="post">
<input type="hidden" name="intent" value="delete-event" /> <input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} /> <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"> <AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</Form>
</AlertDialogFooter> </AlertDialogFooter>
</Form>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})}
</div> </div>
)} )}
</CardContent> </CardContent>

View file

@ -21,6 +21,7 @@ import {
getSportsSeasonsByTournament, getSportsSeasonsByTournament,
getPrimaryEventForTournament, getPrimaryEventForTournament,
setPrimaryEvent, setPrimaryEvent,
deleteScoringEvent,
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils"; import { isBracketMajor } from "~/lib/event-utils";
import { import {
@ -30,6 +31,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } 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 { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { 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 { return {
success: false as const, success: false as const,
error: "Invalid intent", error: "Invalid intent",
@ -236,6 +268,7 @@ export default function AdminTournamentDetail({
} = loaderData; } = loaderData;
const retryFetcher = useFetcher<typeof action>(); const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = 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. // For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref = const primaryScoringHref =
@ -419,6 +452,51 @@ export default function AdminTournamentDetail({
> >
{link.sportsSeason.status} {link.sportsSeason.status}
</Badge> </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>
</div> </div>
))} ))}