Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
353 lines
13 KiB
TypeScript
353 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,
|
|
countWindowsByTournament,
|
|
getMajorsCompleted,
|
|
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.
|
|
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
|
// stored counter — see getMajorsCompleted.
|
|
let qpStandings = null;
|
|
const scoringRules = null;
|
|
let majorsCompleted = 0;
|
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
|
majorsCompleted = await getMajorsCompleted(params.id);
|
|
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 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 {
|
|
sportsSeason: { ...sportsSeason, majorsCompleted } 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." };
|
|
}
|
|
}
|