* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators Adds a `not_participating` boolean column to `event_results` so admins can mark a participant as not competing in a specific upcoming major (e.g. Alcaraz withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major simulators now query this flag for incomplete events and exclude those participants from the event's draw/field, redistributing probability weight to the remaining field. Admin UI for qualifying major_tournament events gains a "Not Participating" card to mark/unmark withdrawals before the event runs. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Address code review feedback on not-participating flag Security: unmark-not-participating now validates the result exists, belongs to this event, and is actually a DNP row before deleting. mark-not-participating now returns a user-friendly error on duplicate-key constraint violations. Code quality: extract shared getExcludedByEventMap() utility to event-result model, eliminating the duplicated 20-line exclusion-loading block that was copy-pasted into all three simulators. Fix hasParticipantResult() to exclude notParticipating rows so it correctly reflects actual competition participation. Remove optional chaining on the non-optional notParticipatingIds field in the admin UI. Fix misleading empty-state message. Tests: replace the misleading first tennis DNP test (which never used the activeIds variable it created) with a test that explicitly validates the fallback behaviour when too few players remain after exclusion. Add three CS2 DNP tests covering the excluded-team-gets-zero-QP path, the redistribution of wins, and per-event pool independence. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Fix lint errors: replace non-null assertions in CS2 DNP test https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 --------- Co-authored-by: Claude <noreply@anthropic.com>
514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
|
|
import { logger } from "~/lib/logger";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/season-participant";
|
|
import {
|
|
getScoringEventById,
|
|
completeScoringEvent,
|
|
updateScoringEvent,
|
|
} from "~/models/scoring-event";
|
|
import {
|
|
getEventResults,
|
|
createEventResult,
|
|
createEventResultsBulk,
|
|
updateEventResult,
|
|
deleteEventResult,
|
|
getEventResultById,
|
|
getNotParticipatingResults,
|
|
type CreateEventResultData,
|
|
type UpdateEventResultData,
|
|
} from "~/models/event-result";
|
|
import { findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";
|
|
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
import {
|
|
upsertParticipantSeasonResult,
|
|
getSeasonResults,
|
|
} from "~/models/participant-season-result";
|
|
import { processSeasonStandings, processQualifyingEvent } from "~/models/scoring-calculator";
|
|
import { getQPStandings, getQPConfig } from "~/models/qualifying-points";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { upsertTournamentResult } from "~/models/tournament-result";
|
|
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
|
|
|
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 event = await getScoringEventById(params.eventId);
|
|
|
|
if (!event) {
|
|
throw new Response("Event not found", { status: 404 });
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
const results = await getEventResults(params.eventId);
|
|
const participantResults = await findParticipantResultsBySportsSeasonId(params.id);
|
|
|
|
// For final_standings events, also get season results
|
|
let seasonResults = null;
|
|
if (event.eventType === "final_standings") {
|
|
seasonResults = await getSeasonResults(params.id);
|
|
}
|
|
|
|
// For qualifying events, get QP config
|
|
let qpConfig = null;
|
|
if (event.isQualifyingEvent) {
|
|
qpConfig = await getQPConfig(params.id);
|
|
}
|
|
|
|
// For qualifying sports seasons, get QP standings
|
|
let qpStandings = null;
|
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
|
qpStandings = await getQPStandings(params.id);
|
|
}
|
|
|
|
// For qualifying major_tournament events, get not-participating participant IDs
|
|
const notParticipatingIds: Set<string> =
|
|
event.isQualifyingEvent && event.eventType === "major_tournament"
|
|
? new Set(
|
|
(await getNotParticipatingResults(params.eventId)).map(
|
|
(r) => r.seasonParticipantId
|
|
)
|
|
)
|
|
: new Set();
|
|
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
|
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
|
},
|
|
event,
|
|
participants,
|
|
results,
|
|
participantResults,
|
|
seasonResults,
|
|
qpConfig,
|
|
qpStandings,
|
|
notParticipatingIds,
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "mark-qualifying") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Update the event to mark it as a qualifying event
|
|
const db = database();
|
|
await db.update(schema.scoringEvents)
|
|
.set({ isQualifyingEvent: true })
|
|
.where(eq(schema.scoringEvents.id, params.eventId));
|
|
|
|
return { success: "Event marked as qualifying event!" };
|
|
} catch (error) {
|
|
logger.error("Error marking event as qualifying:", error);
|
|
return { error: "Failed to mark event as qualifying" };
|
|
}
|
|
}
|
|
|
|
if (intent === "process-qp") {
|
|
try {
|
|
await processQualifyingEvent(params.eventId);
|
|
return { success: "Qualifying points processed and awarded!" };
|
|
} catch (error) {
|
|
logger.error("Error processing qualifying points:", error);
|
|
return { error: error instanceof Error ? error.message : "Failed to process qualifying points" };
|
|
}
|
|
}
|
|
|
|
if (intent === "complete") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
await completeScoringEvent(params.eventId);
|
|
|
|
// If this is a final_standings event, process season standings
|
|
if (event.eventType === "final_standings") {
|
|
await processSeasonStandings(params.id);
|
|
return { success: "Event completed and fantasy placements assigned to top 8!" };
|
|
}
|
|
|
|
// If this is a qualifying event, process qualifying points then fill 0-QP rows
|
|
if (event.isQualifyingEvent) {
|
|
await processQualifyingEvent(params.eventId);
|
|
|
|
// Write 0-QP rows for all season participants who have no result for this event.
|
|
// This marks them as "competed, earned nothing" so simulations skip this event.
|
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
const existingResults = await getEventResults(params.eventId);
|
|
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
|
|
const missingIds = findParticipantsWithoutResults(
|
|
allParticipants.map((p) => p.id),
|
|
existingIds
|
|
);
|
|
|
|
if (missingIds.length > 0) {
|
|
await createEventResultsBulk(
|
|
missingIds.map((participantId) => ({
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
qualifyingPointsAwarded: 0,
|
|
}))
|
|
);
|
|
}
|
|
|
|
return { success: "Event completed and qualifying points awarded!" };
|
|
}
|
|
|
|
return { success: "Event marked as completed" };
|
|
} catch (error) {
|
|
logger.error("Error completing event:", error);
|
|
return { error: "Failed to complete event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "uncomplete") {
|
|
try {
|
|
await updateScoringEvent(params.eventId, { isComplete: false });
|
|
return { success: "Event marked as not updated" };
|
|
} catch (error) {
|
|
logger.error("Error uncompleting event:", error);
|
|
return { error: "Failed to update event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-event") {
|
|
const name = formData.get("name");
|
|
const eventStartsAtRaw = formData.get("eventStartsAt");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Event name is required" };
|
|
}
|
|
|
|
// eventStartsAt is a UTC ISO string from the client; empty string means "clear it"
|
|
const eventStartsAt: Date | null | undefined =
|
|
typeof eventStartsAtRaw === "string"
|
|
? eventStartsAtRaw
|
|
? new Date(eventStartsAtRaw)
|
|
: null
|
|
: undefined;
|
|
|
|
// Derive eventDate from eventStartsAt; null when cleared
|
|
const eventDate: Date | null | undefined =
|
|
eventStartsAt !== undefined
|
|
? eventStartsAt
|
|
? new Date(eventStartsAt.toISOString().split("T")[0])
|
|
: null
|
|
: undefined;
|
|
|
|
try {
|
|
await updateScoringEvent(params.eventId, {
|
|
name: name.trim(),
|
|
eventDate,
|
|
eventStartsAt,
|
|
});
|
|
return { success: "Event updated" };
|
|
} catch (error) {
|
|
logger.error("Error updating event:", error);
|
|
return { error: "Failed to update event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "add-result") {
|
|
const participantId = formData.get("participantId");
|
|
const placement = formData.get("placement");
|
|
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: "Participant is required" };
|
|
}
|
|
|
|
if (typeof placement !== "string" || !placement) {
|
|
return { error: "Placement is required" };
|
|
}
|
|
|
|
const placementNum = parseInt(placement, 10);
|
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
|
return { error: "Placement must be between 1 and 100" };
|
|
}
|
|
|
|
const resultData: CreateEventResultData = {
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
placement: placementNum,
|
|
};
|
|
|
|
try {
|
|
await createEventResult(resultData);
|
|
return { success: "Result added successfully" };
|
|
} catch (error) {
|
|
logger.error("Error adding result:", error);
|
|
return { error: "Failed to add result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-result") {
|
|
const resultId = formData.get("resultId");
|
|
const placement = formData.get("placement");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
if (typeof placement !== "string" || !placement) {
|
|
return { error: "Placement is required" };
|
|
}
|
|
|
|
const placementNum = parseInt(placement, 10);
|
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
|
return { error: "Placement must be between 1 and 100" };
|
|
}
|
|
|
|
const updateData: UpdateEventResultData = {
|
|
placement: placementNum,
|
|
};
|
|
|
|
try {
|
|
await updateEventResult(resultId, updateData);
|
|
return { success: "Result updated successfully" };
|
|
} catch (error) {
|
|
logger.error("Error updating result:", error);
|
|
return { error: "Failed to update result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete-result") {
|
|
const resultId = formData.get("resultId");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
try {
|
|
await deleteEventResult(resultId);
|
|
return { success: "Result deleted successfully" };
|
|
} catch (error) {
|
|
logger.error("Error deleting result:", error);
|
|
return { error: "Failed to delete result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-standings") {
|
|
// Bulk update season standings for final_standings events
|
|
try {
|
|
// Parse all form fields and collect participants with points
|
|
const participantPoints: Array<{ participantId: string; points: number }> = [];
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith("points-")) {
|
|
const participantId = key.replace("points-", "");
|
|
const points = value ? parseFloat(value as string) : 0;
|
|
|
|
if (points > 0) {
|
|
participantPoints.push({ participantId, points });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by points descending to determine positions
|
|
participantPoints.sort((a, b) => b.points - a.points);
|
|
|
|
// Assign positions based on sorted order and update
|
|
for (let i = 0; i < participantPoints.length; i++) {
|
|
const { participantId, points } = participantPoints[i];
|
|
const position = i + 1; // Position is 1-based
|
|
|
|
await upsertParticipantSeasonResult(
|
|
{
|
|
participantId,
|
|
sportsSeasonId: params.id,
|
|
currentPoints: points,
|
|
currentPosition: position,
|
|
}
|
|
);
|
|
}
|
|
|
|
// Also update participants with 0 or no points (they don't have a position)
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith("points-")) {
|
|
const participantId = key.replace("points-", "");
|
|
const points = value ? parseFloat(value as string) : 0;
|
|
|
|
if (points === 0) {
|
|
await upsertParticipantSeasonResult(
|
|
{
|
|
participantId,
|
|
sportsSeasonId: params.id,
|
|
currentPoints: 0,
|
|
currentPosition: undefined,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: `Updated standings for ${participantPoints.length} participants (positions auto-calculated from points)` };
|
|
} catch (error) {
|
|
logger.error("Error updating season standings:", error);
|
|
return { error: "Failed to update season standings" };
|
|
}
|
|
}
|
|
|
|
if (intent === "batch-add-results") {
|
|
const resultsJson = formData.get("results");
|
|
if (typeof resultsJson !== "string" || !resultsJson) {
|
|
return { error: "Results data is required" };
|
|
}
|
|
|
|
let incoming: { participantId: string; placement: number }[];
|
|
try {
|
|
incoming = JSON.parse(resultsJson);
|
|
} catch {
|
|
return { error: "Invalid results format" };
|
|
}
|
|
|
|
if (!Array.isArray(incoming) || incoming.length === 0) {
|
|
return { error: "No results provided" };
|
|
}
|
|
|
|
// Validate that all participant IDs belong to this sports season
|
|
const allSeasonParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
const validParticipantIds = new Set(allSeasonParticipants.map((p) => p.id));
|
|
const invalidEntries = incoming.filter((r) => !validParticipantIds.has(r.participantId));
|
|
if (invalidEntries.length > 0) {
|
|
return { error: "Some participant IDs do not belong to this sports season" };
|
|
}
|
|
|
|
try {
|
|
// If this scoring event is linked to a canonical tournament, write
|
|
// results to the canonical layer and fan out via syncTournamentResults.
|
|
// This keeps sibling windows (if any) in sync automatically.
|
|
const scoringEvent = await database().query.scoringEvents.findFirst({
|
|
where: eq(schema.scoringEvents.id, params.eventId),
|
|
});
|
|
if (!scoringEvent) {
|
|
return { error: "Scoring event not found" };
|
|
}
|
|
|
|
// Qualifying events always have a canonical tournament link (check
|
|
// constraint). Write results canonically and fan out.
|
|
if (!scoringEvent.tournamentId) {
|
|
return {
|
|
error:
|
|
"This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.",
|
|
};
|
|
}
|
|
|
|
const spRows = allSeasonParticipants.filter((sp) =>
|
|
incoming.some((r) => r.participantId === sp.id),
|
|
);
|
|
const missingCanonical = spRows.filter((sp) => !sp.participantId);
|
|
if (missingCanonical.length > 0) {
|
|
return {
|
|
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
|
|
};
|
|
}
|
|
|
|
for (const row of incoming) {
|
|
const sp = spRows.find((s) => s.id === row.participantId);
|
|
if (!sp?.participantId) continue;
|
|
await upsertTournamentResult({
|
|
tournamentId: scoringEvent.tournamentId,
|
|
participantId: sp.participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
|
|
return {
|
|
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
|
|
syncReport,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error saving batch results:", error);
|
|
return { error: "Failed to save results" };
|
|
}
|
|
}
|
|
|
|
if (intent === "mark-not-participating") {
|
|
const participantId = formData.get("participantId");
|
|
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: "Participant ID is required" };
|
|
}
|
|
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) return { error: "Event not found" };
|
|
if (event.isComplete) return { error: "Cannot mark not-participating on a completed event" };
|
|
|
|
try {
|
|
await createEventResult({
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
notParticipating: true,
|
|
});
|
|
return { success: "Participant marked as not participating" };
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes("event_results_event_participant_unique")) {
|
|
return { error: "This participant is already marked for this event" };
|
|
}
|
|
logger.error("Error marking not-participating:", error);
|
|
return { error: "Failed to mark participant as not participating" };
|
|
}
|
|
}
|
|
|
|
if (intent === "unmark-not-participating") {
|
|
const resultId = formData.get("resultId");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
const result = await getEventResultById(resultId);
|
|
if (!result) return { error: "Result not found" };
|
|
if (result.scoringEvent.id !== params.eventId) return { error: "Result does not belong to this event" };
|
|
if (!result.notParticipating) return { error: "Cannot remove a result that is not a not-participating marker" };
|
|
|
|
try {
|
|
await deleteEventResult(resultId);
|
|
return { success: "Participant re-added to event" };
|
|
} catch (error) {
|
|
logger.error("Error unmarking not-participating:", error);
|
|
return { error: "Failed to re-add participant to event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "create-participant") {
|
|
const name = formData.get("name");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Participant name is required" };
|
|
}
|
|
|
|
try {
|
|
const participant = await createParticipant({
|
|
name: name.trim(),
|
|
sportsSeasonId: params.id,
|
|
});
|
|
return {
|
|
success: `Participant "${participant.name}" created.`,
|
|
newParticipant: { id: participant.id, name: participant.name },
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error creating participant:", error);
|
|
if (error instanceof Error && error.message.includes("participants_sports_season_name_unique")) {
|
|
return { error: `A participant named "${name.trim()}" already exists in this sports season.` };
|
|
}
|
|
return { error: "Failed to create participant" };
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid action" };
|
|
}
|