- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet Sixteen winners entering the first scoring round get a floor - AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8])) instead of one shared avg([5-8]) pool - Eliminated teams now always show their correct final rank (e.g. T33 for NCAAM R64 losers) even during partial round scoring, by deriving the rank label from the total match count per round rather than the dynamic still-alive count - Discord notifications now fire when a drafted participant is eliminated with 0 points (e.g. R64 losers whose team score doesn't change) - Discord notifications no longer include all prior matches — scoped to the current batch via matchIds - Discord section headers have spacing; rank medals replaced with numbers - Rounds auto-complete when all matches are marked done; manual Complete Round dropdown removed - Fixed double Discord notification when the last match in a round triggers auto-complete: processPlayoffEvent now accepts skipRecalculate so the caller controls when the notification fires - Fixed falsy check on finalPosition=0 in calculateTeamScore - Undrafted 0-point participants filtered from Eliminated Teams display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
9 KiB
TypeScript
265 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 { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
|
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),
|
|
});
|
|
|
|
let templateId: string | undefined;
|
|
|
|
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;
|
|
|
|
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
|
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
|
}
|
|
|
|
// 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, 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") {
|
|
// 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,
|
|
};
|
|
}
|