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
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import type { Route } from "./+types/admin.sports-seasons.$id.events";
|
|
import { redirect } from "react-router";
|
|
import { logger } from "~/lib/logger";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import {
|
|
getScoringEventsForSportsSeason,
|
|
createScoringEvent,
|
|
deleteScoringEvent,
|
|
bulkCreateScoringEvents,
|
|
ensurePrimaryEvent,
|
|
getSportsSeasonsByTournament,
|
|
type CreateScoringEventData,
|
|
} from "~/models/scoring-event";
|
|
import { isBracketMajor } from "~/lib/event-utils";
|
|
import { getQPStandings } from "~/models/qualifying-points";
|
|
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
|
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
|
import { upsertTournament, findTournamentsBySport, getTournamentById } from "~/models/tournament";
|
|
import { extractTournamentIdentity } from "~/lib/tournament-identity";
|
|
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
}
|
|
|
|
const events = await getScoringEventsForSportsSeason(params.id);
|
|
|
|
// For qualifying sports seasons, get QP standings with global ranks attached
|
|
let qpStandings = null;
|
|
const scoringRules = null;
|
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
|
const standings = await getQPStandings(params.id);
|
|
let prevQP = -1;
|
|
let prevRankStart = 1;
|
|
qpStandings = standings.map((s, index) => {
|
|
const qp = parseFloat(s.totalQualifyingPoints);
|
|
let rank: number;
|
|
if (index > 0 && Math.abs(qp - prevQP) < 0.001) {
|
|
rank = prevRankStart;
|
|
} else {
|
|
rank = index + 1;
|
|
prevRankStart = rank;
|
|
}
|
|
prevQP = qp;
|
|
return { ...s, globalRank: rank };
|
|
});
|
|
}
|
|
|
|
// Tournaments for this sport that don't already have a scoring event in this season.
|
|
// Only relevant for qualifying_points seasons — skip the query for all others.
|
|
const allTournamentsForSport =
|
|
sportsSeason.scoringPattern === "qualifying_points"
|
|
? await findTournamentsBySport(sportsSeason.sportId)
|
|
: [];
|
|
const linkedTournamentIds = new Set(
|
|
events.map((e) => e.tournamentId).filter((id): id is string => id !== null)
|
|
);
|
|
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: eventsWithSharing,
|
|
qpStandings,
|
|
scoringRules,
|
|
availableTournaments,
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "add-from-tournament") {
|
|
const tournamentId = formData.get("tournamentId");
|
|
if (typeof tournamentId !== "string" || !tournamentId) {
|
|
return { error: "Tournament ID is required" };
|
|
}
|
|
try {
|
|
const tournament = await getTournamentById(tournamentId);
|
|
if (!tournament) return { error: "Tournament not found" };
|
|
const eventDate = tournament.startsAt ? new Date(tournament.startsAt) : undefined;
|
|
// This action is only reachable for qualifying_points seasons (the card is
|
|
// conditional in the UI), so major_tournament + isQualifyingEvent=true is always correct.
|
|
const event = await createScoringEvent({
|
|
sportsSeasonId: params.id,
|
|
name: `${tournament.name} ${tournament.year}`,
|
|
eventType: "major_tournament",
|
|
isQualifyingEvent: true,
|
|
tournamentId: tournament.id,
|
|
eventDate,
|
|
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
|
|
});
|
|
// Bracket majors (tennis/CS2) need a primary window to be scorable. Seed it
|
|
// on the first linked window; later windows stay siblings (idempotent).
|
|
const ss = await findSportsSeasonById(params.id);
|
|
if (isBracketMajor(ss?.sport?.simulatorType)) {
|
|
await ensurePrimaryEvent(tournament.id, event.id);
|
|
}
|
|
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
|
} catch (error) {
|
|
logger.error("Error adding scoring event from tournament:", error);
|
|
return { error: "Failed to create scoring event from tournament." };
|
|
}
|
|
}
|
|
|
|
if (intent === "finalize-qp") {
|
|
try {
|
|
await finalizeQualifyingPoints(params.id);
|
|
await maybeResolveCompletedBracktForSportsSeason(params.id);
|
|
return { success: "Qualifying points finalized and fantasy placements assigned to top 8!" };
|
|
} catch (error) {
|
|
logger.error("Error finalizing qualifying points:", error);
|
|
return { error: error instanceof Error ? error.message : "Failed to finalize qualifying points" };
|
|
}
|
|
}
|
|
|
|
if (intent === "bulk-create") {
|
|
const bulkText = formData.get("bulkEvents");
|
|
const bulkEventType = formData.get("bulkEventType");
|
|
|
|
if (typeof bulkText !== "string" || !bulkText.trim()) {
|
|
return { error: "Event list is required" };
|
|
}
|
|
|
|
const validTypes = ["playoff_game", "major_tournament", "schedule_event"] as const;
|
|
type ValidEventType = typeof validTypes[number];
|
|
const eventType: ValidEventType =
|
|
validTypes.includes(bulkEventType as ValidEventType)
|
|
? (bulkEventType as ValidEventType)
|
|
: "playoff_game";
|
|
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
if (!sportsSeason) return { error: "Sports season not found" };
|
|
|
|
const isQualifyingDefault =
|
|
sportsSeason.scoringPattern === "qualifying_points" &&
|
|
eventType === "major_tournament";
|
|
|
|
// Parse lines: "Name, YYYY-MM-DD[, HH:MM]" or tab-separated, or just "Name"
|
|
const lines = bulkText
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
|
|
const parsedEvents = lines.map((line) => {
|
|
const parts = line.split(/[,\t]/).map((p) => p.trim());
|
|
const name = parts[0];
|
|
const dateStr = parts[1];
|
|
const timeStr = parts[2];
|
|
const eventDate =
|
|
dateStr && /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
|
|
? new Date(dateStr)
|
|
: undefined;
|
|
const eventStartsAt =
|
|
eventDate && dateStr && timeStr && /^\d{2}:\d{2}$/.test(timeStr)
|
|
? new Date(`${dateStr}T${timeStr}:00.000Z`)
|
|
: undefined;
|
|
return { name, eventDate, eventStartsAt, eventType, isQualifyingEvent: isQualifyingDefault };
|
|
});
|
|
|
|
const validEvents = parsedEvents.filter((e) => e.name);
|
|
if (validEvents.length === 0) {
|
|
return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" };
|
|
}
|
|
|
|
// Auto-provision canonical tournaments for qualifying events (check constraint).
|
|
try {
|
|
for (const ev of validEvents) {
|
|
if (!ev.isQualifyingEvent) continue;
|
|
const identity = extractTournamentIdentity({
|
|
name: ev.name,
|
|
eventDate: ev.eventDate ? ev.eventDate.toISOString().slice(0, 10) : null,
|
|
eventType: ev.eventType,
|
|
});
|
|
const tournament = await upsertTournament({
|
|
sportId: sportsSeason.sportId,
|
|
name: identity.name,
|
|
year: identity.year,
|
|
startsAt: ev.eventStartsAt ?? null,
|
|
});
|
|
(ev as typeof ev & { tournamentId: string }).tournamentId = tournament.id;
|
|
}
|
|
} catch (err) {
|
|
logger.error("Error linking qualifying events to canonical tournaments:", err);
|
|
return {
|
|
error:
|
|
"Could not determine tournament identity for one or more qualifying events. Include a 4-digit year or date for each.",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const created = await bulkCreateScoringEvents(params.id, validEvents);
|
|
// Seed a primary window per bracket-major tournament that lacks one.
|
|
if (isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
|
for (const ev of created) {
|
|
if (ev.tournamentId) await ensurePrimaryEvent(ev.tournamentId, ev.id);
|
|
}
|
|
}
|
|
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
|
|
} catch (error) {
|
|
logger.error("Error bulk creating events:", error);
|
|
return { error: "Failed to create events. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete-event") {
|
|
const eventId = formData.get("eventId");
|
|
|
|
if (typeof eventId !== "string" || !eventId) {
|
|
return { error: "Event ID is required" };
|
|
}
|
|
|
|
try {
|
|
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" };
|
|
}
|
|
}
|
|
|
|
const name = formData.get("name");
|
|
const eventType = formData.get("eventType");
|
|
const eventStartsAtRaw = formData.get("eventStartsAt");
|
|
|
|
// Validation
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Event name is required" };
|
|
}
|
|
|
|
if (
|
|
eventType !== "playoff_game" &&
|
|
eventType !== "major_tournament" &&
|
|
eventType !== "schedule_event"
|
|
) {
|
|
return { error: "Invalid event type" };
|
|
}
|
|
|
|
// Get the sports season to check if this should be a qualifying event
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
if (!sportsSeason) {
|
|
return { error: "Sports season not found" };
|
|
}
|
|
|
|
// Automatically mark major tournaments as qualifying events for qualifying_points sports seasons
|
|
const isQualifyingEvent =
|
|
sportsSeason.scoringPattern === "qualifying_points" &&
|
|
eventType === "major_tournament";
|
|
|
|
// eventStartsAt is a UTC ISO string produced by localDateTimeToUtcIso on the client
|
|
const eventStartsAt =
|
|
typeof eventStartsAtRaw === "string" && eventStartsAtRaw
|
|
? new Date(eventStartsAtRaw)
|
|
: undefined;
|
|
|
|
// Derive eventDate from eventStartsAt (UTC date portion)
|
|
const eventDate = eventStartsAt ? new Date(eventStartsAt.toISOString().split("T")[0]) : undefined;
|
|
|
|
const eventData: CreateScoringEventData = {
|
|
sportsSeasonId: params.id,
|
|
name: name.trim(),
|
|
eventType: eventType as "playoff_game" | "major_tournament" | "schedule_event",
|
|
eventDate,
|
|
eventStartsAt,
|
|
isQualifyingEvent,
|
|
};
|
|
|
|
// Qualifying events require a canonical tournament_id (check constraint).
|
|
// Auto-provision one by extracting (name, year) from the event name/date.
|
|
if (isQualifyingEvent) {
|
|
try {
|
|
const identity = extractTournamentIdentity({
|
|
name: eventData.name,
|
|
eventDate: eventDate ? eventDate.toISOString().slice(0, 10) : null,
|
|
eventType,
|
|
});
|
|
const tournament = await upsertTournament({
|
|
sportId: sportsSeason.sportId,
|
|
name: identity.name,
|
|
year: identity.year,
|
|
startsAt: eventStartsAt ?? null,
|
|
});
|
|
eventData.tournamentId = tournament.id;
|
|
} catch (err) {
|
|
logger.error("Error linking qualifying event to canonical tournament:", err);
|
|
return {
|
|
error:
|
|
"Could not determine tournament identity. Include a 4-digit year in the event name, or set a date.",
|
|
};
|
|
}
|
|
}
|
|
|
|
try {
|
|
const event = await createScoringEvent(eventData);
|
|
// Bracket majors need a primary window to be scorable — seed it on the first
|
|
// linked window (idempotent for later windows).
|
|
if (eventData.tournamentId && isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
|
await ensurePrimaryEvent(eventData.tournamentId, event.id);
|
|
}
|
|
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
|
} catch (error) {
|
|
logger.error("Error creating scoring event:", error);
|
|
return { error: "Failed to create scoring event. Please try again." };
|
|
}
|
|
}
|