When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
9 KiB
TypeScript
266 lines
9 KiB
TypeScript
import { getAuth } from "@clerk/react-router/server";
|
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
|
import {
|
|
findLeagueById,
|
|
isUserLeagueMember,
|
|
isCommissioner,
|
|
findTeamsBySeasonId,
|
|
} 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,
|
|
} from "~/models/scoring-event";
|
|
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 { userId } = await getAuth(args);
|
|
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.clerkId, ownerIds),
|
|
columns: { clerkId: true, username: true, displayName: true },
|
|
})
|
|
: [];
|
|
const ownerMap = new Map(
|
|
ownerUserRows.map((u) => [u.clerkId, u.username || u.displayName || "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 } };
|
|
|
|
let playoffMatches: any[] = [];
|
|
let playoffRounds: string[] = [];
|
|
let preEliminatedParticipants: { id: string; name: string }[] = [];
|
|
let participantPoints: { participantId: string; points: number }[] = [];
|
|
let partialScoreParticipantIds: string[] = [];
|
|
let seasonStandings: SeasonStanding[] = [];
|
|
let qpStandings: any[] = [];
|
|
|
|
if (scoringPattern === "playoff_bracket") {
|
|
// Fetch playoff matches via scoring events
|
|
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: (playoffMatches, { inArray }) =>
|
|
inArray(playoffMatches.scoringEventId, eventIds),
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
playoffMatches = matches;
|
|
|
|
const roundMatchCounts = new Map<string, number>();
|
|
for (const m of matches) {
|
|
roundMatchCounts.set(m.round, (roundMatchCounts.get(m.round) || 0) + 1);
|
|
}
|
|
playoffRounds = Array.from(roundMatchCounts.keys()).sort(
|
|
(a, b) => (roundMatchCounts.get(b) || 0) - (roundMatchCounts.get(a) || 0)
|
|
);
|
|
}
|
|
|
|
// Fetch group-stage losers and all participant results for bracket scoring
|
|
const [eliminatedResults, allResults] = await Promise.all([
|
|
db.query.participantResults.findMany({
|
|
where: and(
|
|
eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.participantResults.finalPosition, 0)
|
|
),
|
|
with: { participant: true },
|
|
}),
|
|
db.query.participantResults.findMany({
|
|
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
|
|
}),
|
|
]);
|
|
|
|
preEliminatedParticipants = eliminatedResults
|
|
.filter((r) => r.participant)
|
|
.map((r) => ({ id: (r as any).participant.id, name: (r as any).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!, scoringRules),
|
|
}));
|
|
|
|
// 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") {
|
|
// Fetch qualifying points standings
|
|
const standings = await getQPStandings(sportsSeasonId);
|
|
qpStandings = standings;
|
|
}
|
|
|
|
// Fetch event schedule for all patterns (upcoming + recent)
|
|
const [upcomingEvents, recentEvents] = await Promise.all([
|
|
getUpcomingScoringEvents(sportsSeasonId, 5),
|
|
getRecentCompletedEvents(sportsSeasonId, 3),
|
|
]);
|
|
|
|
// Derive seasonIsFinalized from status
|
|
const seasonIsFinalized = sportsSeason.status === "completed";
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
sportsSeason,
|
|
scoringPattern,
|
|
playoffMatches,
|
|
playoffRounds,
|
|
preEliminatedParticipants,
|
|
participantPoints,
|
|
partialScoreParticipantIds,
|
|
seasonStandings,
|
|
qpStandings,
|
|
teamOwnerships,
|
|
userParticipantIds,
|
|
upcomingEvents,
|
|
recentEvents,
|
|
seasonIsFinalized,
|
|
};
|
|
}
|