claude/wimbledon-qp-scoring-bug-xevhlf #127
15 changed files with 491 additions and 154 deletions
|
|
@ -3,7 +3,6 @@ import {
|
|||
calculateSplitQualifyingPoints,
|
||||
DEFAULT_QP_VALUES,
|
||||
diffChangedQualifyingPoints,
|
||||
hasProcessedQualifyingPlacement,
|
||||
} from "../qualifying-points";
|
||||
|
||||
describe("diffChangedQualifyingPoints", () => {
|
||||
|
|
@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
|
|||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
||||
});
|
||||
|
||||
it("should not increment majorsCompleted when reprocessing", () => {
|
||||
// First processing
|
||||
let majorsCompleted = 0;
|
||||
const wasAlreadyProcessed = false;
|
||||
|
||||
if (!wasAlreadyProcessed) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1);
|
||||
|
||||
// Reprocessing (wasAlreadyProcessed = true)
|
||||
const reprocessing = true;
|
||||
|
||||
if (!reprocessing) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processed event detection", () => {
|
||||
it("treats a placed zero-QP result as already processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 20, qualifyingPointsAwarded: "0.00" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat filler zero-QP rows without placements as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: null, qualifyingPointsAwarded: "0" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat unprocessed placed rows as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 15, qualifyingPointsAwarded: null },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scoring Workflow", () => {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,14 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
});
|
||||
|
||||
describe("processQualifyingEvent", () => {
|
||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||
function makeProcessQPMockDb(
|
||||
eventResults: Array<Record<string, unknown>>,
|
||||
options: {
|
||||
tournamentId?: string | null;
|
||||
canonicalPlacements?: number[];
|
||||
} = {}
|
||||
) {
|
||||
const { tournamentId = null, canonicalPlacements = [] } = options;
|
||||
const setCalls: unknown[] = [];
|
||||
const updateChain = {
|
||||
set: (values: unknown) => {
|
||||
|
|
@ -217,6 +224,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
isQualifyingEvent: true,
|
||||
tournamentId,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
|
|
@ -238,6 +246,13 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
// Canonical tournament_results lookup for the structural tie span:
|
||||
// db.select({placement}).from(tournamentResults).where(...) → rows.
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async () => canonicalPlacements.map((placement) => ({ placement })),
|
||||
}),
|
||||
}),
|
||||
update: () => updateChain,
|
||||
} as any;
|
||||
|
||||
|
|
@ -273,6 +288,81 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
||||
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
||||
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
||||
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
||||
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
||||
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
// Both present players earn the correct split of 1.50, not 2.00.
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
||||
// No canonical field exists for a manual/standalone qualifying event, so the
|
||||
// tie span is the players actually present: 2 players tied at 9th →
|
||||
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: null,
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||
const { db, setCalls } = makeProcessQPMockDb([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
|||
import { deleteScoringEvent } from "../scoring-event";
|
||||
|
||||
describe("deleteScoringEvent", () => {
|
||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
||||
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
||||
// majorsCompleted is no longer a stored counter — it is computed via
|
||||
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
||||
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
||||
const setCalls: unknown[] = [];
|
||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||
const updateChain = {
|
||||
|
|
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
|
|||
},
|
||||
]),
|
||||
},
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "sports-season-1",
|
||||
majorsCompleted: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => deleteChain),
|
||||
|
|
@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => {
|
|||
|
||||
await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ majorsCompleted: 0 }),
|
||||
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints(
|
|||
return roundQualifyingPoints(totalQP / tieCount);
|
||||
}
|
||||
|
||||
export function hasProcessedQualifyingPlacement(
|
||||
results: Array<{
|
||||
placement?: number | null;
|
||||
qualifyingPointsAwarded?: string | null;
|
||||
}>
|
||||
): boolean {
|
||||
return results.some(
|
||||
(result) =>
|
||||
result.placement !== null &&
|
||||
result.placement !== undefined &&
|
||||
result.qualifyingPointsAwarded !== null &&
|
||||
result.qualifyingPointsAwarded !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default qualifying point configuration for a sports season
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import {
|
|||
calculateSplitQualifyingPoints,
|
||||
diffChangedQualifyingPoints,
|
||||
getQPConfig,
|
||||
hasProcessedQualifyingPlacement,
|
||||
recalculateParticipantQP,
|
||||
writeEventResultsQP,
|
||||
getQPStandings,
|
||||
|
|
@ -854,7 +853,8 @@ export async function processQualifyingBracketEvent(
|
|||
*/
|
||||
export async function processQualifyingEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options: { skipNotifications?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -877,9 +877,6 @@ export async function processQualifyingEvent(
|
|||
// Get all event results for this qualifying event
|
||||
const results = await getEventResults(eventId, db);
|
||||
|
||||
// Check if this was already processed (for majorsCompleted counter)
|
||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||
|
||||
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||
// announce only the participants whose QP actually changed. Without this, a
|
||||
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
||||
|
|
@ -914,6 +911,31 @@ export async function processQualifyingEvent(
|
|||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Full-field tie span. The number of players tied at a placement is a property
|
||||
// of the whole tournament field (canonical tournament_results), NOT of who
|
||||
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
|
||||
// draftable subset of the field, so counting the placements present locally
|
||||
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
|
||||
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
|
||||
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
|
||||
// the span from canonical results keeps every window/league identical. Standalone
|
||||
// events (no tournamentId) have no canonical field, so fall back to the live count.
|
||||
let canonicalTieCountByPlacement: Map<number, number> | null = null;
|
||||
if (event.tournamentId) {
|
||||
const canonicalResults = await db
|
||||
.select({ placement: schema.tournamentResults.placement })
|
||||
.from(schema.tournamentResults)
|
||||
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId));
|
||||
canonicalTieCountByPlacement = new Map();
|
||||
for (const cr of canonicalResults) {
|
||||
if (cr.placement === null) continue;
|
||||
canonicalTieCountByPlacement.set(
|
||||
cr.placement,
|
||||
(canonicalTieCountByPlacement.get(cr.placement) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
|
|
@ -926,7 +948,7 @@ export async function processQualifyingEvent(
|
|||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = group.length;
|
||||
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
|
|
@ -954,17 +976,10 @@ export async function processQualifyingEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
||||
if (!wasAlreadyProcessed) {
|
||||
const sportsSeason = event.sportsSeason;
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({
|
||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||
|
||||
logger.log(
|
||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||
|
|
@ -981,7 +996,7 @@ export async function processQualifyingEvent(
|
|||
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
||||
);
|
||||
|
||||
if (changedParticipantIds.size > 0) {
|
||||
if (changedParticipantIds.size > 0 && !options.skipNotifications) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
import { findParticipantNamesByIds } from "./season-participant";
|
||||
import { deleteTournament } from "./tournament";
|
||||
import type { EventType } from "./scoring-event-types";
|
||||
|
|
@ -144,6 +144,33 @@ export async function getQualifyingEvents(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of "majors completed" for a sports season, derived on read as the count of
|
||||
* qualifying events that have been marked complete. Replaces the old stored
|
||||
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
||||
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
||||
* self-correcting and immune to double-counting.
|
||||
*/
|
||||
export async function getMajorsCompleted(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.scoringEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
eq(schema.scoringEvents.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a scoring event
|
||||
*/
|
||||
|
|
@ -246,13 +273,11 @@ export async function deleteScoringEvent(
|
|||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||
// eventResults (which is how we know who had QP awarded from this event).
|
||||
let affectedParticipantIds: string[] = [];
|
||||
let wasQPProcessed = false;
|
||||
if (event.isQualifyingEvent) {
|
||||
const results = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
|
@ -275,18 +300,9 @@ export async function deleteScoringEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// Decrement majorsCompleted if this event had already been processed
|
||||
if (wasQPProcessed) {
|
||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
||||
});
|
||||
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
}
|
||||
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||
// number. No stored counter to decrement.
|
||||
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -882,9 +882,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
||||
if (event.isQualifyingEvent) {
|
||||
const db = database();
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
|
||||
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
||||
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
||||
// completed qualifying events (see getMajorsCompleted) — marking this event
|
||||
// complete below is what advances it. Season-wide fantasy finalization stays with
|
||||
// finalizeQualifyingPoints across all majors.
|
||||
await processQualifyingEvent(params.eventId, db);
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
countWindowsByTournament,
|
||||
getMajorsCompleted,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
|
|
@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const events = await getScoringEventsForSportsSeason(params.id);
|
||||
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached
|
||||
// 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;
|
||||
|
|
@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
});
|
||||
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
},
|
||||
events: eventsWithSharing,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points";
|
|||
import {
|
||||
getUpcomingScoringEvents,
|
||||
getRecentCompletedEvents,
|
||||
getMajorsCompleted,
|
||||
} from "~/models/scoring-event";
|
||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||
|
|
@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
let seasonStandings: SeasonStanding[] = [];
|
||||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
||||
let qpStandings: QPStanding[] = [];
|
||||
let majorsCompleted = 0;
|
||||
|
||||
// Group standings for group-stage events (e.g. FIFA World Cup)
|
||||
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
|
||||
|
|
@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
},
|
||||
}));
|
||||
} else if (scoringPattern === "qualifying_points") {
|
||||
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||
// stored counter — see getMajorsCompleted.
|
||||
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
|
||||
const standings = await getQPStandings(sportsSeasonId);
|
||||
// Compute global ranks with tie handling across the full field before filtering,
|
||||
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
|
||||
|
|
@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
return {
|
||||
league,
|
||||
season,
|
||||
sportsSeason,
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted },
|
||||
scoringPattern,
|
||||
playoffMatches,
|
||||
playoffRounds,
|
||||
|
|
|
|||
|
|
@ -680,11 +680,35 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
vi.stubGlobal("fetch", mockFetch(204));
|
||||
});
|
||||
|
||||
const BASE_ENTRIES = [
|
||||
// The caller now supplies each entry's rank in the FULL season field. This helper
|
||||
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
|
||||
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
|
||||
// tests keep asserting ranks derived from qpTotal.
|
||||
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
|
||||
const sorted = [...entries].sort((a, b) => b.qpTotal - a.qpTotal);
|
||||
const rankByTotal = new Map<number, number>();
|
||||
let prevTotal = Number.NaN;
|
||||
let prevRank = 0;
|
||||
sorted.forEach((e, i) => {
|
||||
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
|
||||
rankByTotal.set(e.qpTotal, rank);
|
||||
prevTotal = e.qpTotal;
|
||||
prevRank = rank;
|
||||
});
|
||||
const countByTotal = new Map<number, number>();
|
||||
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
|
||||
return entries.map((e) => ({
|
||||
...e,
|
||||
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
|
||||
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
|
||||
}));
|
||||
}
|
||||
|
||||
const BASE_ENTRIES = withRanks([
|
||||
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
||||
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
||||
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
||||
];
|
||||
]);
|
||||
|
||||
it("sends an embed with gold color and QP title", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
|
|
@ -759,11 +783,11 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
entries: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -772,11 +796,74 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
expect(desc).toContain("3\\. Player C");
|
||||
});
|
||||
|
||||
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
|
||||
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
|
||||
// (positions 9–16 split) but Discord rounded it to 2 via Math.round.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
sportName: "Tennis - Men",
|
||||
eventName: "Wimbledon",
|
||||
entries: [
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 1.5,
|
||||
qpTotal: 1.5,
|
||||
globalRank: 9,
|
||||
globalRankTied: true,
|
||||
ownerUsername: "snarkymcgee",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
// Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"),
|
||||
// never rounded to an integer.
|
||||
expect(desc).toContain("+1.50 QP");
|
||||
expect(desc).not.toContain("+2 QP");
|
||||
expect(desc).toContain("1.50 QP");
|
||||
expect(desc).not.toContain("— 2 QP");
|
||||
});
|
||||
|
||||
it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => {
|
||||
// Screenshot scenario: only two players scored this event, but they sit at T9 in
|
||||
// the full season field (8 players ahead). They must show T9, not T1.
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Rumble League 2026",
|
||||
sportName: "Tennis - Men",
|
||||
eventName: "Wimbledon",
|
||||
entries: [
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 1.5,
|
||||
qpTotal: 1.5,
|
||||
globalRank: 9,
|
||||
globalRankTied: true,
|
||||
ownerUsername: "snarkymcgee",
|
||||
},
|
||||
{
|
||||
participantName: "Jannik Sinner",
|
||||
qpEarned: 1.5,
|
||||
qpTotal: 1.5,
|
||||
globalRank: 9,
|
||||
globalRankTied: true,
|
||||
ownerUsername: "tanay002",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP");
|
||||
expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP");
|
||||
expect(desc).not.toContain("T1\\.");
|
||||
});
|
||||
|
||||
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
entries: withRanks([
|
||||
{
|
||||
participantName: "Novak Djokovic",
|
||||
qpEarned: 20,
|
||||
|
|
@ -784,7 +871,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
ownerUsername: "alex",
|
||||
ownerDiscordUserId: "111222333",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -796,10 +883,10 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
entries: withRanks([
|
||||
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
||||
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const payload = getPayload();
|
||||
|
|
@ -864,9 +951,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
seasonName: "Slam League 2025",
|
||||
entries: [
|
||||
entries: withRanks([
|
||||
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const desc = getDescription();
|
||||
|
|
@ -875,12 +962,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
|||
});
|
||||
|
||||
it("truncates description at 4096 characters", async () => {
|
||||
const longEntries = Array.from({ length: 200 }, (_, i) => ({
|
||||
participantName: `Very Long Participant Name Number ${i}`,
|
||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||
qpTotal: 200 - i,
|
||||
ownerUsername: `owner_with_long_username_${i}`,
|
||||
}));
|
||||
const longEntries = withRanks(
|
||||
Array.from({ length: 200 }, (_, i) => ({
|
||||
participantName: `Very Long Participant Name Number ${i}`,
|
||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||
qpTotal: 200 - i,
|
||||
ownerUsername: `owner_with_long_username_${i}`,
|
||||
}))
|
||||
);
|
||||
|
||||
await sendQualifyingPointsUpdateNotification({
|
||||
webhookUrl: WEBHOOK_URL,
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ describe("syncTournamentResults", () => {
|
|||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { skipNotifications: false });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -412,8 +412,8 @@ describe("syncTournamentResults", () => {
|
|||
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { skipNotifications: false });
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { skipNotifications: false });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -723,7 +723,7 @@ describe("syncTournamentResults", () => {
|
|||
|
||||
expect(report.windowsSynced).toBe(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { skipNotifications: false });
|
||||
// No event_results written for the skipped primary window.
|
||||
expect(
|
||||
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ function escapeMarkdown(text: string): string {
|
|||
return text.replace(/[_*~`|\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
|
||||
* earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly
|
||||
* and fractional values to 2 decimals. Mirrors the web UI's formatQP
|
||||
* (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree.
|
||||
*/
|
||||
function formatQPValue(n: number): string {
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||
}
|
||||
|
||||
export interface StandingEntry {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
|
|
@ -251,6 +261,15 @@ export interface QPEventEntry {
|
|||
participantName: string;
|
||||
qpEarned: number;
|
||||
qpTotal: number;
|
||||
/**
|
||||
* The participant's rank in the FULL season QP standings (all participants), not
|
||||
* their position among this event's scorers. Computed by the caller so the "QP
|
||||
* Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP
|
||||
* show as T9 (8 players ahead) rather than T1 among just the two of them.
|
||||
*/
|
||||
globalRank: number;
|
||||
/** True when another participant in the full field shares this globalRank. */
|
||||
globalRankTied: boolean;
|
||||
ownerUsername?: string;
|
||||
ownerDiscordUserId?: string;
|
||||
}
|
||||
|
|
@ -301,7 +320,7 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
const label = managerLabel
|
||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||
: escapeMarkdown(e.participantName);
|
||||
sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`);
|
||||
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -338,33 +357,23 @@ export async function sendQualifyingPointsUpdateNotification({
|
|||
}
|
||||
}
|
||||
|
||||
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
||||
// Compute ranks with tie detection
|
||||
const ranks: number[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
|
||||
ranks.push(ranks[ranks.length - 1]);
|
||||
} else {
|
||||
ranks.push(i + 1);
|
||||
}
|
||||
}
|
||||
const isTied = buildTiedRankChecker(ranks);
|
||||
// Order by the participant's rank in the FULL season standings (globalRank),
|
||||
// supplied by the caller, so the block reads as a season leaderboard slice.
|
||||
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
|
||||
|
||||
// The standings block reflects QP earners; skip it entirely when this sync only
|
||||
// reported knockouts (no QP change) so we don't emit an empty header.
|
||||
if (sorted.length > 0) {
|
||||
sections.push("\n**QP Standings**");
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const e = sorted[i];
|
||||
const r = ranks[i];
|
||||
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
|
||||
for (const e of sorted) {
|
||||
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||
const ownerLabel = e.ownerDiscordUserId
|
||||
? `<@${e.ownerDiscordUserId}>`
|
||||
: e.ownerUsername
|
||||
? escapeMarkdown(e.ownerUsername)
|
||||
: undefined;
|
||||
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
|
||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${Math.round(e.qpTotal)} QP`);
|
||||
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate(
|
|||
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
||||
);
|
||||
|
||||
// Rank every participant across the FULL season field (not just this event's
|
||||
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
|
||||
// slice and matches the website's globalRank. Standard competition ranking: ties
|
||||
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
|
||||
// ahead therefore render as T9, not T1 among just the two of them.
|
||||
const rankedField = [...qpTotalById.entries()]
|
||||
.map(([id, total]) => ({ id, total }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
const globalRankById = new Map<string, number>();
|
||||
let prevTotal = Number.NaN;
|
||||
let prevRank = 0;
|
||||
rankedField.forEach((row, index) => {
|
||||
const rank =
|
||||
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
|
||||
globalRankById.set(row.id, rank);
|
||||
prevTotal = row.total;
|
||||
prevRank = rank;
|
||||
});
|
||||
const countByRank = new Map<number, number>();
|
||||
for (const rank of globalRankById.values()) {
|
||||
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Batch-fetch all season + league metadata in one query
|
||||
const seasons = await db.query.seasons.findMany({
|
||||
where: inArray(schema.seasons.id, seasonIds),
|
||||
|
|
@ -158,10 +181,13 @@ export async function notifyQualifyingPointsUpdate(
|
|||
|
||||
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||
return {
|
||||
participantName: participantNameById.get(participantId) ?? participantId,
|
||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||
globalRank,
|
||||
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ export interface SyncOptions {
|
|||
* primary is already scored in place, so re-running it would be wasted work.
|
||||
*/
|
||||
skipEventId?: string;
|
||||
/**
|
||||
* Suppress ALL Discord notifications for this fan-out (both the per-window QP
|
||||
* update from processQualifyingEvent and the league standings recalc). Used by
|
||||
* one-off backfills that re-score historical events — the QP values change
|
||||
* (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league.
|
||||
*/
|
||||
skipNotifications?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +64,7 @@ export async function syncTournamentResults(
|
|||
options: SyncOptions = {}
|
||||
): Promise<SyncReport> {
|
||||
const db = database();
|
||||
const { markComplete = true, skipEventId } = options;
|
||||
const { markComplete = true, skipEventId, skipNotifications = false } = options;
|
||||
|
||||
const report: SyncReport = {
|
||||
tournamentId,
|
||||
|
|
@ -176,7 +183,7 @@ export async function syncTournamentResults(
|
|||
}
|
||||
|
||||
// 3d. Delegate to scoring engine inside the same transaction.
|
||||
await processQualifyingEvent(ev.id, tx);
|
||||
await processQualifyingEvent(ev.id, tx, { skipNotifications });
|
||||
|
||||
// 3e. Mark the window event complete (final-results sync only). This is
|
||||
// what makes "score once" actually complete every window — without it,
|
||||
|
|
@ -213,8 +220,9 @@ export async function syncTournamentResults(
|
|||
eventName: w.eventName ?? undefined,
|
||||
// Mid-tournament fan-out (markComplete=false) updates standings silently;
|
||||
// the primary window already announced the round. Only the final sync
|
||||
// (completion) announces to each window's leagues.
|
||||
skipDiscord: !markComplete,
|
||||
// (completion) announces to each window's leagues. A backfill
|
||||
// (skipNotifications) is always silent.
|
||||
skipDiscord: skipNotifications || !markComplete,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
|
|
@ -250,9 +258,9 @@ export async function syncTournamentResults(
|
|||
*/
|
||||
export async function syncMajorFromPrimaryEvent(
|
||||
primaryEventId: string,
|
||||
options: { markComplete?: boolean } = {}
|
||||
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
|
||||
): Promise<SyncReport> {
|
||||
const { markComplete = false } = options;
|
||||
const { markComplete = false, skipNotifications = false } = options;
|
||||
|
||||
const primaryEvent = await getScoringEventById(primaryEventId);
|
||||
if (!primaryEvent) {
|
||||
|
|
@ -317,6 +325,7 @@ export async function syncMajorFromPrimaryEvent(
|
|||
return syncTournamentResults(tournamentId, {
|
||||
markComplete,
|
||||
skipEventId: primaryEventId,
|
||||
skipNotifications,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
136
scripts/backfill-qp-resplit.ts
Normal file
136
scripts/backfill-qp-resplit.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Backfill: re-score qualifying majors so mis-split QP is corrected.
|
||||
*
|
||||
* Sibling/mirror windows used to compute a placement's tie span from the players
|
||||
* present on THAT window's roster (a subset of the field), so a tied group split
|
||||
* fewer ways and over-awarded — tennis Round-of-16 losers landed at 2 QP instead of
|
||||
* the correct 1.5 (positions 9–16: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now
|
||||
* derives the tie span from the canonical full field, so re-running the fan-out
|
||||
* rewrites the stored event_results QP, participant totals, and league standings.
|
||||
*
|
||||
* This is idempotent and SILENT: notifications are suppressed so re-scoring history
|
||||
* (2 → 1.5 for many participants) does not re-ping every league's Discord. It does
|
||||
* NOT re-link events or designate primaries — run backfill-major-linking.ts first if
|
||||
* the data predates the shared-major model. majorsCompleted needs no fix (it is now
|
||||
* derived on read from completed qualifying events).
|
||||
*
|
||||
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
|
||||
*
|
||||
* npx tsx scripts/backfill-qp-resplit.ts # apply
|
||||
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
|
||||
*/
|
||||
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "../database/schema.js";
|
||||
import { DatabaseContext, database } from "../database/context.js";
|
||||
import { isBracketMajor } from "../app/lib/event-utils.js";
|
||||
import {
|
||||
syncTournamentResults,
|
||||
syncMajorFromPrimaryEvent,
|
||||
} from "../app/services/sync-tournament-results.js";
|
||||
|
||||
const DRY = process.argv.includes("--dry");
|
||||
const log = (...a: unknown[]) => console.log(...a);
|
||||
|
||||
async function run() {
|
||||
const db = database();
|
||||
|
||||
// Every tournament-linked qualifying event, grouped by canonical tournament.
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
});
|
||||
const byTournament = new Map<string, typeof events>();
|
||||
for (const ev of events) {
|
||||
if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out
|
||||
const arr = byTournament.get(ev.tournamentId) ?? [];
|
||||
arr.push(ev);
|
||||
byTournament.set(ev.tournamentId, arr);
|
||||
}
|
||||
|
||||
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [tournamentId, evs] of byTournament) {
|
||||
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
|
||||
const bracketMajor = isBracketMajor(simulatorType);
|
||||
const name = evs[0]?.name ?? tournamentId;
|
||||
|
||||
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
|
||||
const anyComplete = evs.some((e) => e.isComplete);
|
||||
const markComplete = evs.every((e) => e.isComplete);
|
||||
|
||||
if (DRY) {
|
||||
log(
|
||||
` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` +
|
||||
`complete=${markComplete} — would re-score silently`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bracketMajor) {
|
||||
// Re-derive canonical from the primary, then fan out to siblings (which now
|
||||
// split ties by the full field). Primary is scored in place and skipped.
|
||||
const primary = evs.find((e) => e.isPrimary);
|
||||
if (!primary) {
|
||||
log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const report = await syncMajorFromPrimaryEvent(primary.id, {
|
||||
markComplete,
|
||||
skipNotifications: true,
|
||||
});
|
||||
ok += report.windowsSynced;
|
||||
failed += report.windowsFailed;
|
||||
log(
|
||||
` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
||||
);
|
||||
} else {
|
||||
// Golf/placement: canonical tournament_results already exist; just fan out.
|
||||
if (!anyComplete) {
|
||||
log(` ${name}: no completed window — skipping`);
|
||||
continue;
|
||||
}
|
||||
const report = await syncTournamentResults(tournamentId, {
|
||||
markComplete,
|
||||
skipNotifications: true,
|
||||
});
|
||||
ok += report.windowsSynced;
|
||||
failed += report.windowsFailed;
|
||||
log(
|
||||
` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
failed += 1;
|
||||
log(` ! ${name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
console.error("ERROR: DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
const client = postgres(dbUrl, { max: 1 });
|
||||
const db = drizzle(client, { schema });
|
||||
try {
|
||||
await DatabaseContext.run(db, run);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue