brackt/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
Chris Parsons cf8ac8a765
feat: progressive floor scoring for playoff brackets (#100)
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>
2026-03-10 10:27:58 -07:00

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,
};
}