Commissioners can add Brackt to any league. Each team drafts one manager from their own league; at season end, that manager's final overall fantasy ranking earns placement points. Resolution fires automatically when all other sports finalize. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 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,
|
|
type CreateScoringEventData,
|
|
} from "~/models/scoring-event";
|
|
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
|
|
let qpStandings = null;
|
|
const scoringRules = null;
|
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
|
qpStandings = await getQPStandings(params.id);
|
|
}
|
|
|
|
// 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));
|
|
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
|
sport: { id: string; name: string; type: string; slug: string };
|
|
},
|
|
events,
|
|
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,
|
|
});
|
|
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 {
|
|
await bulkCreateScoringEvents(params.id, validEvents);
|
|
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 {
|
|
await deleteScoringEvent(eventId);
|
|
return { success: "Event deleted successfully" };
|
|
} 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);
|
|
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." };
|
|
}
|
|
}
|