brackt/app/routes/admin.sports-seasons.$id.events.server.ts
Claude efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
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
2026-07-03 20:40:47 +00:00

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." };
}
}