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
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { auth } from "~/lib/auth.server";
|
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
|
import {
|
|
findLeagueById,
|
|
isUserLeagueMember,
|
|
isCommissioner,
|
|
findTeamsBySeasonId,
|
|
getUserDisplayName,
|
|
} from "~/models";
|
|
import { getDraftPicks } from "~/models/draft-pick";
|
|
import { getSeasonResults } from "~/models/participant-season-result";
|
|
import { calculateBracketPoints } from "~/models/scoring-rules";
|
|
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";
|
|
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
|
import {
|
|
findMatchesByGroupIds,
|
|
computeGroupStandings,
|
|
} from "~/models/group-stage-match";
|
|
import { findGroupsByEventId } from "~/models/tournament-group";
|
|
import type { PlayoffMatch } from "~/models/playoff-match";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, inArray } from "drizzle-orm";
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const { params } = args;
|
|
const { leagueId, sportsSeasonId } = params;
|
|
|
|
// Check authentication
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view this page", {
|
|
status: 401,
|
|
});
|
|
}
|
|
|
|
// Fetch league
|
|
const league = await findLeagueById(leagueId);
|
|
if (!league) {
|
|
throw new Response("League not found", { status: 404 });
|
|
}
|
|
|
|
// Check access: user must be a commissioner or member
|
|
const isUserCommissioner = await isCommissioner(leagueId, userId);
|
|
const isUserMember = await isUserLeagueMember(leagueId, userId);
|
|
|
|
if (!isUserCommissioner && !isUserMember) {
|
|
throw new Response("You do not have access to this league", {
|
|
status: 403,
|
|
});
|
|
}
|
|
|
|
// Fetch sports season with relations
|
|
const db = database();
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
|
with: {
|
|
sport: true,
|
|
seasonSports: {
|
|
with: {
|
|
season: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
}
|
|
|
|
// Get the fantasy season for this league (to get teams and draft picks)
|
|
// We need to find which season in this league includes this sports season
|
|
// Filter by leagueId since a sports season can be linked to multiple leagues
|
|
const seasonSport = sportsSeason.seasonSports?.find(
|
|
(ss) => ss.season.leagueId === leagueId
|
|
);
|
|
if (!seasonSport) {
|
|
throw new Response("This sports season is not linked to this league", {
|
|
status: 404,
|
|
});
|
|
}
|
|
|
|
const season = seasonSport.season;
|
|
|
|
// Fetch teams and draft picks to determine ownership
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const draftPicks = await getDraftPicks(season.id);
|
|
|
|
// Build ownership map: participantId -> { teamName, teamId, ownerName }
|
|
const ownershipMap = new Map<
|
|
string,
|
|
{ teamName: string; teamId: string; ownerName?: string }
|
|
>();
|
|
|
|
// Get unique owner IDs and batch-fetch their user records in a single query
|
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
|
|
const ownerUserRows = ownerIds.length > 0
|
|
? await db.query.users.findMany({
|
|
where: inArray(schema.users.id, ownerIds),
|
|
columns: { id: true, username: true, displayName: true },
|
|
})
|
|
: [];
|
|
const ownerMap = new Map(
|
|
ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"])
|
|
);
|
|
|
|
// Map draft picks to ownership
|
|
for (const pick of draftPicks) {
|
|
const team = teams.find((t) => t.id === pick.teamId);
|
|
if (team && pick.participantId) {
|
|
ownershipMap.set(pick.participantId, {
|
|
teamName: team.name,
|
|
teamId: team.id,
|
|
ownerName: team.ownerId ? ownerMap.get(team.ownerId) : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert to array for serialization
|
|
const teamOwnerships = Array.from(ownershipMap.entries()).map(
|
|
([participantId, data]) => ({
|
|
participantId,
|
|
...data,
|
|
})
|
|
);
|
|
|
|
// Derive which participant IDs the current user has drafted
|
|
const userTeams = teams.filter((t) => t.ownerId === userId);
|
|
const userTeamIds = new Set(userTeams.map((t) => t.id));
|
|
const userParticipantIds = draftPicks
|
|
.filter((p) => p.teamId && userTeamIds.has(p.teamId) && p.participantId)
|
|
.map((p) => p.participantId as string);
|
|
|
|
// Fetch pattern-specific data based on scoring pattern
|
|
const scoringPattern = sportsSeason.scoringPattern;
|
|
|
|
type SeasonStanding = { id: string; position: number; championshipPoints: string; participant: { id: string; name: string } };
|
|
|
|
type PlayoffMatchWithRelations = PlayoffMatch & {
|
|
participant1: { id: string; name: string } | null;
|
|
participant2: { id: string; name: string } | null;
|
|
winner: { id: string; name: string } | null;
|
|
loser: { id: string; name: string } | null;
|
|
};
|
|
let playoffMatches: PlayoffMatchWithRelations[] = [];
|
|
let playoffRounds: string[] = [];
|
|
let preEliminatedParticipants: { id: string; name: string }[] = [];
|
|
let participantPoints: { participantId: string; points: number }[] = [];
|
|
let partialScoreParticipantIds: string[] = [];
|
|
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;
|
|
type GroupStandingData = {
|
|
groupName: string;
|
|
standings: ReturnType<typeof computeGroupStandings>;
|
|
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { scheduledAt: string | null }>;
|
|
};
|
|
let groupStandings: GroupStandingData[] = [];
|
|
|
|
let bracketTemplateId: string | null = null;
|
|
|
|
if (scoringPattern === "playoff_bracket") {
|
|
// Fetch playoff matches via scoring events
|
|
let templateId: string | undefined;
|
|
const events = await db.query.scoringEvents.findMany({
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
});
|
|
|
|
if (events.length > 0) {
|
|
const eventIds = events.map((e) => e.id);
|
|
const matches = await db.query.playoffMatches.findMany({
|
|
where: (pm) => inArray(pm.scoringEventId, eventIds),
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
playoffMatches = matches as PlayoffMatchWithRelations[];
|
|
|
|
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
|
bracketTemplateId = templateId ?? null;
|
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
|
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
|
|
|
// Load group stage standings if this event uses a group-stage template
|
|
if (template?.groupStage) {
|
|
const groupStageEventId = events.find((e) => e.bracketTemplateId === templateId)?.id;
|
|
if (groupStageEventId) {
|
|
const groups = await findGroupsByEventId(groupStageEventId);
|
|
// Single batched query for all groups instead of N round-trips
|
|
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
|
|
groupStandings = groups.map((group) => {
|
|
const groupMatches = matchesByGroupId.get(group.id) ?? [];
|
|
const members = (group.members ?? [])
|
|
.filter((m) => m.participant !== null && m.participant !== undefined)
|
|
.map((m) => ({
|
|
participantId: m.participant?.id ?? "",
|
|
participantName: m.participant?.name ?? "",
|
|
}));
|
|
return {
|
|
groupName: group.groupName,
|
|
standings: computeGroupStandings(
|
|
members,
|
|
groupMatches.map((m) => ({
|
|
participant1Id: m.participant1Id,
|
|
participant2Id: m.participant2Id,
|
|
participant1Score: m.participant1Score,
|
|
participant2Score: m.participant2Score,
|
|
isComplete: m.isComplete,
|
|
}))
|
|
),
|
|
matches: groupMatches.map((m) => ({
|
|
...m,
|
|
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch group-stage losers and all participant results for bracket scoring
|
|
const [eliminatedResults, allResults] = await Promise.all([
|
|
db.query.seasonParticipantResults.findMany({
|
|
where: and(
|
|
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.seasonParticipantResults.finalPosition, 0)
|
|
),
|
|
with: { participant: true },
|
|
}),
|
|
db.query.seasonParticipantResults.findMany({
|
|
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
|
}),
|
|
]);
|
|
|
|
preEliminatedParticipants = eliminatedResults
|
|
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null && r.participant !== undefined)
|
|
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
|
|
|
|
// Compute per-participant fantasy points directly from participant_results.
|
|
// This covers both finalized placements and progressive floor scores for still-alive participants.
|
|
const scoringRules = {
|
|
pointsFor1st: season.pointsFor1st,
|
|
pointsFor2nd: season.pointsFor2nd,
|
|
pointsFor3rd: season.pointsFor3rd,
|
|
pointsFor4th: season.pointsFor4th,
|
|
pointsFor5th: season.pointsFor5th,
|
|
pointsFor6th: season.pointsFor6th,
|
|
pointsFor7th: season.pointsFor7th,
|
|
pointsFor8th: season.pointsFor8th,
|
|
};
|
|
const seenParticipantIds = new Set<string>();
|
|
participantPoints = allResults
|
|
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
|
|
.filter((r) => {
|
|
if (seenParticipantIds.has(r.participantId)) return false;
|
|
seenParticipantIds.add(r.participantId);
|
|
return true;
|
|
})
|
|
.map((r) => ({
|
|
participantId: r.participantId,
|
|
points: calculateBracketPoints(r.finalPosition ?? 0, scoringRules, templateId),
|
|
}));
|
|
|
|
// Track which participants have provisional (floor) scores — still competing
|
|
partialScoreParticipantIds = allResults
|
|
.filter((r) => r.isPartialScore)
|
|
.map((r) => r.participantId);
|
|
} else if (scoringPattern === "season_standings") {
|
|
// Fetch F1-style championship standings and map to SeasonStanding shape
|
|
const results = await getSeasonResults(sportsSeasonId);
|
|
seasonStandings = results
|
|
.filter((r) => r.currentPosition !== null || (r.currentPoints && parseFloat(r.currentPoints) > 0))
|
|
.map((r) => ({
|
|
id: r.id,
|
|
position: r.currentPosition ?? 999,
|
|
championshipPoints: r.currentPoints ?? "0",
|
|
participant: {
|
|
id: r.participant.id,
|
|
name: r.participant.name,
|
|
},
|
|
}));
|
|
} 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.
|
|
let prevQP = -1;
|
|
let prevRankStart = 1;
|
|
const withGlobalRank = 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 };
|
|
});
|
|
qpStandings = withGlobalRank.filter(
|
|
(s) =>
|
|
parseFloat(s.totalQualifyingPoints) > 0 ||
|
|
ownershipMap.has(s.participant.id)
|
|
);
|
|
}
|
|
|
|
// Fetch event schedule for all patterns (upcoming + recent)
|
|
const [upcomingEvents, recentEvents] = await Promise.all([
|
|
getUpcomingScoringEvents(sportsSeasonId, 5),
|
|
getRecentCompletedEvents(sportsSeasonId, 3),
|
|
]);
|
|
|
|
// Fetch regular season standings for team-sport playoff_bracket seasons (NBA, NHL)
|
|
const isTeamBracket =
|
|
scoringPattern === "playoff_bracket" && sportsSeason.sport?.type === "team";
|
|
const [regularSeasonStandings, regularSeasonEvs] = isTeamBracket
|
|
? await Promise.all([
|
|
getRegularSeasonStandings(sportsSeasonId),
|
|
getAllParticipantEVsForSeason(sportsSeasonId),
|
|
])
|
|
: [[], []];
|
|
|
|
// Build participantId → expectedValue map for display
|
|
const participantEvs = Object.fromEntries(
|
|
regularSeasonEvs.map((ev) => [ev.participantId, ev.expectedValue])
|
|
);
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
sportsSeason: { ...sportsSeason, majorsCompleted },
|
|
scoringPattern,
|
|
playoffMatches,
|
|
playoffRounds,
|
|
preEliminatedParticipants,
|
|
participantPoints,
|
|
partialScoreParticipantIds,
|
|
seasonStandings,
|
|
qpStandings,
|
|
teamOwnerships,
|
|
userParticipantIds,
|
|
upcomingEvents,
|
|
recentEvents,
|
|
regularSeasonStandings,
|
|
participantEvs,
|
|
groupStandings,
|
|
bracketTemplateId,
|
|
};
|
|
}
|