A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import { useLoaderData, Link } from "react-router";
|
|
import { eq, asc, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { requireLeagueAccess } from "~/lib/auth";
|
|
import { DraftGrid } from "~/components/DraftGrid";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useState, useEffect } from "react";
|
|
import { findUsersByIds, getUserDisplayName } from "~/models/user";
|
|
import { resolveUserAvatarData } from "~/lib/avatar-data";
|
|
import { Button } from "~/components/ui/button";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import logomarkUrl from "../../../public/logomark.svg?url";
|
|
import { computeCoronaStates } from "~/lib/corona-states";
|
|
import { getSharedPlacementCounts } from "~/models/participant-result";
|
|
import type { CoronaState } from "~/components/draft/DraftPickCell";
|
|
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Draft Board — ${data?.season?.league?.name ?? "League"} - Brackt` }];
|
|
}
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const { params } = args;
|
|
const { leagueId, seasonId } = params;
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
// Get season details
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
with: {
|
|
league: true,
|
|
},
|
|
});
|
|
|
|
if (!season) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Validate that the season actually belongs to the league in the URL
|
|
if (season.leagueId !== leagueId) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Check access: public boards are accessible to everyone without auth
|
|
if (!season.league.isPublicDraftBoard) {
|
|
await requireLeagueAccess(args, {
|
|
leagueId: season.leagueId,
|
|
seasonId,
|
|
db,
|
|
unauthMessage: "This draft board is not public",
|
|
unauthorizedMessage: "You don't have access to this draft board",
|
|
});
|
|
}
|
|
|
|
// Get draft slots (draft order)
|
|
const draftSlots = await db
|
|
.select({
|
|
id: schema.draftSlots.id,
|
|
draftOrder: schema.draftSlots.draftOrder,
|
|
team: schema.teams,
|
|
})
|
|
.from(schema.draftSlots)
|
|
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
|
|
.where(eq(schema.draftSlots.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftSlots.draftOrder));
|
|
|
|
// Get all draft picks with participant and sport info
|
|
const draftPicks = await db
|
|
.select({
|
|
id: schema.draftPicks.id,
|
|
pickNumber: schema.draftPicks.pickNumber,
|
|
round: schema.draftPicks.round,
|
|
pickInRound: schema.draftPicks.pickInRound,
|
|
team: schema.teams,
|
|
participant: schema.seasonParticipants,
|
|
sport: schema.sports,
|
|
scoringPattern: schema.sportsSeasons.scoringPattern,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.seasonParticipants,
|
|
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
|
|
.where(eq(schema.draftPicks.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftPicks.pickNumber));
|
|
|
|
const uniqueOwnerIds = [...new Set(
|
|
draftSlots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
|
|
)];
|
|
const ownerUserRows = uniqueOwnerIds.length ? await findUsersByIds(uniqueOwnerIds) : [];
|
|
const ownerUserById = new Map(ownerUserRows.map((u) => [u.id, u]));
|
|
|
|
const ownerMap: Record<string, string> = {};
|
|
const enrichedDraftSlots = draftSlots.map((slot) => {
|
|
const ownerUser = slot.team.ownerId ? ownerUserById.get(slot.team.ownerId) : undefined;
|
|
const displayName = ownerUser ? getUserDisplayName(ownerUser) : null;
|
|
if (slot.team.ownerId && displayName) ownerMap[slot.team.id] = displayName;
|
|
return {
|
|
...slot,
|
|
team: { ...slot.team, ownerAvatarData: ownerUser ? resolveUserAvatarData(ownerUser) : null },
|
|
};
|
|
});
|
|
|
|
let coronaStates: Record<string, CoronaState> = {};
|
|
|
|
if (draftPicks.length > 0) {
|
|
const participantIds = draftPicks.map((p) => p.participant.id);
|
|
const sportsSeasonIds = [...new Set(draftPicks.map((p) => p.participant.sportsSeasonId))];
|
|
|
|
const results = await db
|
|
.select({
|
|
participantId: schema.seasonParticipantResults.participantId,
|
|
sportsSeasonId: schema.seasonParticipantResults.sportsSeasonId,
|
|
finalPosition: schema.seasonParticipantResults.finalPosition,
|
|
isPartialScore: schema.seasonParticipantResults.isPartialScore,
|
|
})
|
|
.from(schema.seasonParticipantResults)
|
|
.where(inArray(schema.seasonParticipantResults.participantId, participantIds));
|
|
|
|
const resultByParticipant = new Map(results.map((r) => [r.participantId, r]));
|
|
|
|
const bracketTemplateBySportsSeason = new Map<string, string | null>();
|
|
if (sportsSeasonIds.length > 0) {
|
|
const events = await db
|
|
.select({
|
|
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
|
|
bracketTemplateId: schema.scoringEvents.bracketTemplateId,
|
|
})
|
|
.from(schema.scoringEvents)
|
|
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
|
|
for (const ev of events) {
|
|
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
|
|
bracketTemplateBySportsSeason.set(ev.sportsSeasonId, ev.bracketTemplateId);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
// Counted across every participant in each sports season, not just the
|
|
// drafted ones above — an undrafted player tied for the same placement still
|
|
// halves the award.
|
|
const sharedPlacementCountsBySportsSeason =
|
|
await getSharedPlacementCounts(sportsSeasonIds, db);
|
|
|
|
coronaStates = computeCoronaStates(
|
|
draftPicks,
|
|
resultByParticipant,
|
|
bracketTemplateBySportsSeason,
|
|
scoringRules,
|
|
season.pointsFor1st,
|
|
sharedPlacementCountsBySportsSeason,
|
|
);
|
|
}
|
|
|
|
return {
|
|
season,
|
|
draftSlots: enrichedDraftSlots,
|
|
draftPicks,
|
|
ownerMap,
|
|
coronaStates,
|
|
};
|
|
}
|
|
|
|
export default function DraftBoard() {
|
|
const { season, draftSlots, draftPicks: initialPicks, ownerMap, coronaStates } = useLoaderData<typeof loader>();
|
|
const { isConnected, on, off } = useDraftSocket(season.id);
|
|
const [picks, setPicks] = useState(initialPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
|
|
// Listen for new picks (only if draft is still active)
|
|
useEffect(() => {
|
|
if (season.status !== "draft") return;
|
|
|
|
type PickShape = (typeof initialPicks)[number];
|
|
const handlePickMade = (data: unknown) => {
|
|
const pickData = data as { pick: PickShape; nextPickNumber: number };
|
|
setPicks((prev) => [...prev, pickData.pick]);
|
|
setCurrentPick(pickData.nextPickNumber);
|
|
};
|
|
|
|
on("pick-made", handlePickMade);
|
|
|
|
return () => {
|
|
off("pick-made", handlePickMade);
|
|
};
|
|
}, [on, off, season.status]);
|
|
|
|
// Generate draft grid
|
|
const totalTeams = draftSlots.length;
|
|
const totalRounds = season.draftRounds || 1;
|
|
type PickItem = (typeof initialPicks)[number];
|
|
const draftGrid: Array<Array<PickItem | null>> = [];
|
|
|
|
for (let round = 0; round < totalRounds; round++) {
|
|
const roundPicks: Array<PickItem | null> = [];
|
|
for (let teamIndex = 0; teamIndex < totalTeams; teamIndex++) {
|
|
const pickNumber = round * totalTeams + teamIndex + 1;
|
|
const pick = picks.find((p) => p.pickNumber === pickNumber);
|
|
roundPicks.push(pick || null);
|
|
}
|
|
draftGrid.push(roundPicks);
|
|
}
|
|
|
|
const isDraftActive = season.status === "draft";
|
|
const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="border-b bg-card sticky top-0 z-10">
|
|
<div className="w-full px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link to="/">
|
|
<img src={logomarkUrl} alt="Brackt" className="h-8" />
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">
|
|
{season.league.name} Draft Board
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
{isDraftActive && (
|
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
<span>Round {currentRound}</span>
|
|
<span>Pick {currentPick}</span>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-2.5 h-2.5 rounded-full ${
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
|
}`}
|
|
/>
|
|
<span className="font-medium">
|
|
{isConnected ? "Connected" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={`/leagues/${season.leagueId}`}>
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
|
Back to League
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Draft Grid */}
|
|
<div className="w-full py-4 px-2 md:px-4">
|
|
<DraftGrid
|
|
draftSlots={draftSlots}
|
|
draftGrid={draftGrid}
|
|
currentPick={currentPick}
|
|
ownerMap={ownerMap}
|
|
coronaStates={coronaStates}
|
|
seasonStatus={season.status}
|
|
draftPaused={season.draftPaused}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|