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>
This commit is contained in:
Chris Parsons 2026-03-10 10:27:58 -07:00 committed by GitHub
parent d88be08deb
commit cf8ac8a765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 3866 additions and 70 deletions

View file

@ -4,3 +4,4 @@ CLERK_SECRET_KEY=""
CLERK_PUBLISHABLE_KEY="" CLERK_PUBLISHABLE_KEY=""
CLERK_WEBHOOK_SECRET="" CLERK_WEBHOOK_SECRET=""
CONTAINER_REGISTRY="" CONTAINER_REGISTRY=""
DEV_ADMIN_CLERK_ID=""

View file

@ -45,6 +45,7 @@ interface PlayoffBracketProps {
rounds: string[]; // Ordered list of round names (earliest first) rounds: string[]; // Ordered list of round names (earliest first)
preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage) preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage)
participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant
partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores
teamOwnerships?: TeamOwnership[]; teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[]; userParticipantIds?: string[];
showOwnership?: boolean; showOwnership?: boolean;
@ -114,6 +115,7 @@ export function PlayoffBracket({
rounds, rounds,
preEliminatedParticipants = [], preEliminatedParticipants = [],
participantPoints = [], participantPoints = [],
partialScoreParticipantIds = [],
teamOwnerships = [], teamOwnerships = [],
userParticipantIds = [], userParticipantIds = [],
showOwnership = true, showOwnership = true,
@ -135,7 +137,9 @@ export function PlayoffBracket({
return `Winner of ${feeder.round} M${feeder.matchNumber}`; return `Winner of ${feeder.round} M${feeder.matchNumber}`;
}; };
// Build elimination rankings: collect losers per round, then assign rank labels // Build elimination rankings: collect losers per round, then assign rank labels.
// In double-chance brackets (e.g. AFL), a participant may lose one round but
// win a later one — only count them as eliminated at their FINAL losing match.
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>(); const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let hasScore = false; let hasScore = false;
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
@ -146,8 +150,17 @@ export function PlayoffBracket({
: null; : null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner; if (finalMatch?.winner) bracketWinner = finalMatch.winner;
// Build the set of all participants who won at least one completed match.
// If a participant won any match, their earlier loss was not their final elimination.
const participantWinIds = new Set<string>();
for (const match of matches) {
if (match.isComplete && match.winnerId) participantWinIds.add(match.winnerId);
}
for (const match of matches) { for (const match of matches) {
if (!match.isComplete || !match.loser) continue; if (!match.isComplete || !match.loser) continue;
// Skip if this loser went on to win another match (double-chance bracket)
if (participantWinIds.has(match.loser.id)) continue;
const loserScore = const loserScore =
match.loserId === match.participant1Id match.loserId === match.participant1Id
? match.participant1Score ? match.participant1Score
@ -162,21 +175,49 @@ export function PlayoffBracket({
} }
// Walk rounds latest→earliest to assign rank labels (no mutation) // Walk rounds latest→earliest to assign rank labels (no mutation)
// nextRank must start after the still-alive participants (not always 2)
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const totalEliminatedInBracket = [...losersByRound.values()].reduce(
(sum, losers) => sum + losers.length,
0
);
const stillAlive =
allBracketParticipantIds.size - totalEliminatedInBracket - (bracketWinner ? 1 : 0);
const rankedEntries: EliminatedEntry[] = []; const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2; let nextRank = stillAlive + (bracketWinner ? 2 : 1);
for (let ri = rounds.length - 1; ri >= 0; ri--) { for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundLosers = losersByRound.get(rounds[ri]) || []; const roundLosers = losersByRound.get(rounds[ri]) || [];
if (roundLosers.length === 0) continue; if (roundLosers.length === 0) continue;
const rankEnd = nextRank + roundLosers.length - 1; const rankLabel = `T${nextRank}`;
const rankLabel =
nextRank === rankEnd ? `#${nextRank}` : `#${nextRank}${rankEnd}`;
for (const loser of roundLosers) { for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel }); rankedEntries.push({ ...loser, rankLabel });
} }
nextRank += roundLosers.length; nextRank += roundLosers.length;
} }
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || preEliminatedParticipants.length > 0; // Exclude pre-eliminated participants already ranked via bracket match losers
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
const filteredPreEliminated = preEliminatedParticipants.filter(
(p) => !rankedParticipantIds.has(p.id)
);
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
// Build participant lookup from match data for the "In Contention" table
const participantMap = new Map<string, Participant>();
for (const match of matches) {
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
if (match.participant2) participantMap.set(match.participant2.id, match.participant2);
}
const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined);
// Hoist winner row lookups so we don't need an IIFE in JSX // Hoist winner row lookups so we don't need an IIFE in JSX
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
@ -370,7 +411,68 @@ export function PlayoffBracket({
); );
})} })}
{/* Final Rankings */} {/* In Contention */}
{activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
<Star className="h-5 w-5" />
In Contention
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
)}
{pointsMap.size > 0 && (
<TableHead className="text-right w-16">Pts</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{activeParticipants.map((p) => {
const isOwned = userParticipantSet.has(p.id);
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
const pts = pointsMap.get(p.id);
return (
<TableRow
key={p.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric" : ""}
>
<TableCell className={isOwned ? "text-electric font-medium" : ""}>
{p.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right font-mono text-sm">
{pts !== undefined ? pts : ""}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Final Rankings / Eliminated Teams */}
{showRankings && ( {showRankings && (
<Card className="border-electric/30"> <Card className="border-electric/30">
<CardHeader> <CardHeader>
@ -463,7 +565,7 @@ export function PlayoffBracket({
); );
})} })}
{preEliminatedParticipants.map((p) => { {filteredPreEliminated.map((p) => {
const isOwned = userParticipantSet.has(p.id); const isOwned = userParticipantSet.has(p.id);
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
const pts = pointsMap.get(p.id); const pts = pointsMap.get(p.id);

View file

@ -98,6 +98,7 @@ interface SportSeasonDisplayProps {
playoffRounds?: string[]; playoffRounds?: string[];
preEliminatedParticipants?: { id: string; name: string }[]; preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[]; participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
// Season standings data (F1) // Season standings data (F1)
seasonStandings?: SeasonStanding[]; seasonStandings?: SeasonStanding[];
@ -125,6 +126,7 @@ export function SportSeasonDisplay({
playoffRounds = [], playoffRounds = [],
preEliminatedParticipants = [], preEliminatedParticipants = [],
participantPoints = [], participantPoints = [],
partialScoreParticipantIds = [],
seasonStandings = [], seasonStandings = [],
seasonIsFinalized = false, seasonIsFinalized = false,
qpStandings = [], qpStandings = [],
@ -147,6 +149,7 @@ export function SportSeasonDisplay({
rounds={playoffRounds} rounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants} preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints} participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
teamOwnerships={teamOwnerships} teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
showOwnership={showOwnership} showOwnership={showOwnership}

View file

@ -0,0 +1,170 @@
import { describe, it, expect } from "vitest";
import { getGuaranteedMinimumPosition } from "../scoring-calculator";
import { calculateAveragedPoints, calculateBracketPoints, calculateFantasyPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
describe("Progressive Floor Scoring", () => {
describe("getGuaranteedMinimumPosition", () => {
describe("Standard bracket rounds", () => {
it("returns null for Finals — winner is finalized as 1st", () => {
expect(getGuaranteedMinimumPosition("Finals", null, true)).toBeNull();
expect(getGuaranteedMinimumPosition("Championship", null, true)).toBeNull();
expect(getGuaranteedMinimumPosition("Super Bowl", null, true)).toBeNull();
expect(getGuaranteedMinimumPosition("NBA Finals", null, true)).toBeNull();
expect(getGuaranteedMinimumPosition("Grand Final", "afl_10", true)).toBeNull();
});
it("returns 2 for Semifinals — winners go to Final, guaranteed at worst 2nd", () => {
expect(getGuaranteedMinimumPosition("Semifinals", null, true)).toBe(2);
expect(getGuaranteedMinimumPosition("Final Four", null, true)).toBe(2);
expect(getGuaranteedMinimumPosition("Conference Finals", null, true)).toBe(2);
expect(getGuaranteedMinimumPosition("Conference Championship", null, true)).toBe(2);
});
it("returns 3 for Quarterfinals — winners go to Semis, guaranteed at worst 3rd-4th", () => {
expect(getGuaranteedMinimumPosition("Quarterfinals", null, true)).toBe(3);
expect(getGuaranteedMinimumPosition("Elite Eight", null, true)).toBe(3);
expect(getGuaranteedMinimumPosition("Divisional", null, true)).toBe(3);
expect(getGuaranteedMinimumPosition("Conference Semifinals", null, true)).toBe(3);
});
it("returns 5 for earlier scoring rounds — winners are guaranteed top-8", () => {
expect(getGuaranteedMinimumPosition("Round of 16", null, true)).toBe(5);
expect(getGuaranteedMinimumPosition("Round of 32", null, true)).toBe(5);
expect(getGuaranteedMinimumPosition("Wild Card Round", null, true)).toBe(5);
});
it("returns null for non-scoring rounds — winning doesn't guarantee points yet", () => {
expect(getGuaranteedMinimumPosition("Round of 16", null, false)).toBeNull();
expect(getGuaranteedMinimumPosition("First Four", null, false)).toBeNull();
expect(getGuaranteedMinimumPosition("Quarterfinals", null, false)).toBeNull();
});
});
describe("AFL bracket (afl_10)", () => {
it("returns 3 for Qualifying Finals winners — advance to Preliminary Finals", () => {
expect(getGuaranteedMinimumPosition("Qualifying Finals", "afl_10", true)).toBe(3);
});
it("returns 5 for Elimination Finals winners — advance to Semi-Finals", () => {
expect(getGuaranteedMinimumPosition("Elimination Finals", "afl_10", true)).toBe(5);
});
it("returns 3 for AFL Semi-Finals winners — advance to Preliminary Finals", () => {
expect(getGuaranteedMinimumPosition("Semi-Finals", "afl_10", true)).toBe(3);
});
it("returns 2 for Preliminary Finals winners — advance to Grand Final", () => {
expect(getGuaranteedMinimumPosition("Preliminary Finals", "afl_10", true)).toBe(2);
});
});
});
describe("Progressive point values (guaranteed minimum points)", () => {
it("standard bracket: 8-team path to champion earns correct floor at each stage", () => {
// Win Quarterfinals → guaranteed at worst 3rd-4th
const afterQF = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(afterQF).toBe(45); // (50 + 40) / 2
// Win Semifinals → guaranteed at worst 2nd
const afterSF = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(afterSF).toBe(70);
// Win Finals → 1st place
const champion = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(champion).toBe(100);
});
it("standard bracket: losing in Round of 16 earns 0 pts (non-scoring)", () => {
// Non-scoring round losers get eliminated with 0 pts
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
it("standard bracket: Quarterfinal survivors start at 20 pts floor (avg of 5-8)", () => {
// 8 winners from R16 advance to QF. If all 8 lose QF → tied 5th-8th → 20 pts each.
// But only 4 lose QF; the 4 winners advance. Their guaranteed minimum = avg(3, 4).
// The R16 winners' guaranteed minimum (before QF is played) would be avg(5,6,7,8) = 20.
const r16WinnersFloor = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(r16WinnersFloor).toBe(20); // (25+25+15+15)/4
});
it("AFL: Elimination Finals winners get 5th-6th floor (avg)", () => {
// EF winners advance to Semi-Finals. If they lose Semi-Finals → 5th-6th.
const efWinnersFloor = calculateAveragedPoints([5, 6], DEFAULT_SCORING);
expect(efWinnersFloor).toBe(25); // (25+25)/2 = 25
});
it("AFL: Qualifying Finals winners get 3rd-4th floor (avg)", () => {
// QF winners advance to Preliminary Finals. If they lose → 3rd-4th.
const qfWinnersFloor = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(qfWinnersFloor).toBe(45); // (50+40)/2
});
it("AFL: Qualifying Finals losers (second chance) get 5th-6th floor", () => {
// QF losers drop to Semi-Finals. If they lose Semi-Finals → 5th-6th.
const qfLosersFloor = calculateAveragedPoints([5, 6], DEFAULT_SCORING);
expect(qfLosersFloor).toBe(25); // (25+25)/2 = 25
});
});
describe("Non-scoring round provisional floors", () => {
it("R16 winners (non-scoring) get T5 floor = 20 pts", () => {
// After winning a non-scoring R16, participants are guaranteed top-8.
// processPlayoffEvent assigns finalPosition=5 with isPartialScore=true.
// calculateBracketPoints maps position 5 → avg(5-8) = 20 pts.
expect(calculateBracketPoints(5, DEFAULT_SCORING)).toBe(20);
});
it("non-scoring floor (pos 5) is less than QF winner floor (pos 3)", () => {
// Advancing past QF increases the floor from 20 → 45 pts.
const nonScoringFloor = calculateBracketPoints(5, DEFAULT_SCORING);
const afterQFFloor = calculateBracketPoints(3, DEFAULT_SCORING);
expect(afterQFFloor).toBeGreaterThan(nonScoringFloor);
});
it("position 5 stored for non-scoring winner gives correct 20 pts via calculateBracketPoints", () => {
// The non-scoring branch stores finalPosition=5 (fixed).
// calculateBracketPoints correctly averages [5,6,7,8] regardless of how many
// winners the non-scoring round produced.
expect(calculateBracketPoints(5, DEFAULT_SCORING)).toBe(20); // (25+25+15+15)/4
});
});
describe("Progressive scoring progression", () => {
it("score increases as participant advances through bracket", () => {
// A participant's guaranteed minimum only increases, never decreases.
// After each round, verify new floor > previous floor.
const afterR16Floor = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING); // 20
const afterQFFloor = calculateAveragedPoints([3, 4], DEFAULT_SCORING); // 45
const afterSFFloor = calculateFantasyPoints(2, DEFAULT_SCORING); // 70
const champion = calculateFantasyPoints(1, DEFAULT_SCORING); // 100
expect(afterQFFloor).toBeGreaterThan(afterR16Floor);
expect(afterSFFloor).toBeGreaterThan(afterQFFloor);
expect(champion).toBeGreaterThan(afterSFFloor);
});
it("final winner's partial score (2nd) is replaced by finalized 1st place score", () => {
// Before Finals: finalist has guaranteed minimum of 2nd place
const finalistFloor = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(finalistFloor).toBe(70);
// After winning Finals: finalized to 1st place
const championScore = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(championScore).toBe(100);
// Score increased when finalized
expect(championScore).toBeGreaterThan(finalistFloor);
});
});
});

View file

@ -1,7 +1,7 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules"; import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result"; import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
@ -16,14 +16,16 @@ export type ScoringPattern =
| "qualifying_points"; | "qualifying_points";
/** /**
* Process a playoff event completion and assign final placements * Process a playoff event completion and assign final placements.
* *
* Q19: Admin enters the same placement for all tied participants, * Loser placement rules per round type:
* system automatically shares positions * Non-scoring rounds: losers get 0 pts (pre-bracket elimination)
* Quarterfinals / equivalent: losers share T5T8
* Semifinals / equivalent: losers share T3T4
* Finals / equivalent: loser gets 2nd, winner gets 1st
* *
* Q20: Participants eliminated before Elite Eight get 0 points * Progressive floor scoring: winners immediately earn a provisional placement
* * reflecting their worst-case finish from this point forward.
* Phase 2.7: Updated to support template-based brackets with non-scoring rounds
*/ */
export async function processPlayoffEvent( export async function processPlayoffEvent(
eventId: string, eventId: string,
@ -124,21 +126,49 @@ export async function processPlayoffEvent(
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`); throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
} }
// If this is a non-scoring round, award 0 points (Q20) // Non-scoring rounds: losers are pre-bracket eliminated (0 pts).
// Winners have cleared the qualifier and are guaranteed a top-8 finish,
// so we bank position 5 (T5T8 floor) as their provisional score immediately.
// Using a fixed value of 5 is correct for standard brackets (R16 winners → QF → T5 worst case)
// and conservatively safe for non-standard cases (never over-promises points).
if (!isScoring) { if (!isScoring) {
for (const match of matches) { for (const match of matches) {
if (match.loserId) { if (match.loserId) {
await upsertParticipantResult( await upsertParticipantResult(
match.loserId, match.loserId,
event.sportsSeasonId, event.sportsSeasonId,
0, // 0 placement = 0 points for early elimination 0, // 0 = pre-bracket elimination, earns no points
db db
); );
} }
if (match.winnerId) {
await upsertParticipantResult(
match.winnerId,
event.sportsSeasonId,
5, // provisional T5T8 floor: guaranteed top-8 finish
db,
true
);
}
} }
} }
// AFL-specific rounds (Phase 3.3) // AFL-specific rounds (afl_10 bracket template)
else if (round === "Elimination Finals") { else if (round === "Qualifying Finals") {
// AFL: Qualifying Finals losers get a second chance via Semi-Finals.
// They are not eliminated here, but earn a guaranteed minimum of 5th-6th.
// Winners are handled by the general guaranteed-minimum loop below (3rd-4th).
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
5, // Guaranteed minimum: at worst 5th-6th via Semi-Finals
db,
true // isPartialScore: still competing
);
}
}
} else if (round === "Elimination Finals") {
// AFL: Elimination Finals losers share 7th-8th // AFL: Elimination Finals losers share 7th-8th
for (const match of matches) { for (const match of matches) {
if (match.loserId) { if (match.loserId) {
@ -171,7 +201,7 @@ export async function processPlayoffEvent(
throw new Error("Finals match is not complete"); throw new Error("Finals match is not complete");
} }
// Update or create participant results for winner // Update or create participant results for winner (finalized, isPartialScore=false)
await upsertParticipantResult( await upsertParticipantResult(
finalMatch.winnerId, finalMatch.winnerId,
event.sportsSeasonId, event.sportsSeasonId,
@ -179,7 +209,7 @@ export async function processPlayoffEvent(
db db
); );
// Update or create participant results for loser // Update or create participant results for loser (finalized, isPartialScore=false)
await upsertParticipantResult( await upsertParticipantResult(
finalMatch.loserId, finalMatch.loserId,
event.sportsSeasonId, event.sportsSeasonId,
@ -210,12 +240,52 @@ export async function processPlayoffEvent(
); );
} }
} }
} else {
// Unrecognized scoring round: losers are outside the top-8 and earn 0 pts.
// This handles rounds like "Round of 16", "Round of 32" when isScoring=true,
// or any custom round name not explicitly mapped above.
console.warn(
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
);
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
0,
db
);
}
}
}
// Progressive floor scoring: assign guaranteed minimum points to winners.
// Winners of any scoring round (except Finals, which already finalize both
// participants above) earn provisional points reflecting the worst-case
// placement they can achieve from here. These update as they advance.
const guaranteedMinimum = getGuaranteedMinimumPosition(
round,
event.bracketTemplateId,
isScoring
);
if (guaranteedMinimum !== null) {
for (const match of matches) {
if (match.winnerId) {
await upsertParticipantResult(
match.winnerId,
event.sportsSeasonId,
guaranteedMinimum,
db,
true // isPartialScore: still competing, score will increase as they advance
);
}
}
} }
// Recalculate standings for all affected leagues // Recalculate standings for all affected leagues
await recalculateAffectedLeagues(event.sportsSeasonId, db); await recalculateAffectedLeagues(event.sportsSeasonId, db);
// Auto-trigger probability recalculation (Phase 5.3) // Auto-trigger probability recalculation after result
try { try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true); await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
console.log( console.log(
@ -231,12 +301,17 @@ export async function processPlayoffEvent(
/** /**
* Helper to upsert participant result * Helper to upsert participant result
*
* isPartialScore=true means the participant is still alive and this placement
* is their guaranteed minimum floor it will be replaced as they advance or
* when they are finally eliminated.
*/ */
async function upsertParticipantResult( async function upsertParticipantResult(
participantId: string, participantId: string,
sportsSeasonId: string, sportsSeasonId: string,
finalPosition: number, finalPosition: number,
db: ReturnType<typeof database> db: ReturnType<typeof database>,
isPartialScore = false
): Promise<void> { ): Promise<void> {
const existing = await db.query.participantResults.findFirst({ const existing = await db.query.participantResults.findFirst({
where: and( where: and(
@ -246,10 +321,16 @@ async function upsertParticipantResult(
}); });
if (existing) { if (existing) {
// Never un-finalize: if the row is already finalized (isPartialScore=false),
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
// processPlayoffEvent after a manual finalization).
if (!existing.isPartialScore && isPartialScore) return;
await db await db
.update(schema.participantResults) .update(schema.participantResults)
.set({ .set({
finalPosition, finalPosition,
isPartialScore,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.participantResults.id, existing.id)); .where(eq(schema.participantResults.id, existing.id));
@ -258,16 +339,78 @@ async function upsertParticipantResult(
participantId, participantId,
sportsSeasonId, sportsSeasonId,
finalPosition, finalPosition,
isPartialScore,
}); });
} }
} }
/** /**
* Process a qualifying event completion and update QP totals * Returns the guaranteed minimum final position for winners of a given round.
* Phase 3.2 Implementation
* *
* Q4: Manual finalization after all majors complete * This is the worst-case placement a participant can achieve if they lose
* Q5: Ties in QP handled by sharing placements * every remaining match from this point forward. Returns null when:
* - The round is non-scoring (winners haven't reached a points-paying zone)
* - The round is the Finals (winners are already being finalized as 1st place)
*/
export function getGuaranteedMinimumPosition(
round: string,
bracketTemplateId: string | null | undefined,
isScoring: boolean
): number | null {
// Non-scoring rounds: winning doesn't guarantee any points yet
if (!isScoring) return null;
// Finals: winner is finalized as 1st — handled inline, not via this helper
if (
round === "Finals" ||
round === "Championship" ||
round === "Super Bowl" ||
round === "NBA Finals" ||
round === "Grand Final"
) {
return null;
}
// AFL-specific routing (afl_10 bracket template)
if (bracketTemplateId === "afl_10") {
// Qualifying Finals: winners → Prelim Finals (losers there get 3rd-4th)
if (round === "Qualifying Finals") return 3;
// Elimination Finals: winners → Semi-Finals (losers there get 5th-6th)
if (round === "Elimination Finals") return 5;
// Semi-Finals: winners → Prelim Finals (losers there get 3rd-4th)
if (round === "Semi-Finals") return 3;
}
// Standard bracket rounds
// Semi-final winners advance to the Final — guaranteed at worst 2nd place
if (
round === "Semifinals" ||
round === "Final Four" ||
round === "Conference Finals" ||
round === "Conference Championship" ||
round === "Preliminary Finals"
) {
return 2;
}
// Quarterfinal winners advance to Semis — guaranteed at worst 3rd-4th
if (
round === "Quarterfinals" ||
round === "Elite Eight" ||
round === "Divisional" ||
round === "Conference Semifinals"
) {
return 3;
}
// All other scoring rounds (e.g. Round of 16, Round of 32, etc.) —
// winners are now guaranteed a top-8 finish at worst (position 5)
return 5;
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
*/ */
export async function processQualifyingEvent( export async function processQualifyingEvent(
eventId: string, eventId: string,
@ -363,13 +506,9 @@ export async function processQualifyingEvent(
} }
/** /**
* Finalize qualifying points and convert to fantasy placements * Finalize qualifying points and convert to fantasy placements.
* Called manually by admin when all majors are complete * Called manually by admin when all events are complete.
* Phase 3.2 Implementation * Tied QP totals result in shared placements (averaged points).
*
* Q4: Manual finalization (admin button)
* Q5: QP ties handled by sharing placements
* Q19: Tie entry uses same placement, system auto-calculates shared positions
*/ */
export async function finalizeQualifyingPoints( export async function finalizeQualifyingPoints(
sportsSeasonId: string, sportsSeasonId: string,
@ -478,7 +617,7 @@ export async function finalizeQualifyingPoints(
// Trigger recalculation for all affected leagues // Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db); await recalculateAffectedLeagues(sportsSeasonId, db);
// Auto-trigger probability recalculation (Phase 5.3) // Auto-trigger probability recalculation after result
try { try {
await updateProbabilitiesAfterResult(sportsSeasonId, true); await updateProbabilitiesAfterResult(sportsSeasonId, true);
console.log( console.log(
@ -497,12 +636,9 @@ export async function finalizeQualifyingPoints(
} }
/** /**
* Process season standings (F1) and assign final placements * Process season standings (F1/IndyCar) and assign final placements.
* * Reads from participant_season_results (championship points) and converts
* Q7: For F1, we show current F1 points during the season, then assign fantasy points at the end * final standings positions to fantasy placements. Ties share placements.
* Q8: Just final standings, not individual races
* Q14: Store current running total, manual update with API future
* Q13/Q19: Ties handled by admin entering same position, system auto-shares placements
*/ */
export async function processSeasonStandings( export async function processSeasonStandings(
sportsSeasonId: string, sportsSeasonId: string,
@ -591,7 +727,7 @@ export async function processSeasonStandings(
// Trigger recalculation for all affected leagues // Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db); await recalculateAffectedLeagues(sportsSeasonId, db);
// Auto-trigger probability recalculation (Phase 5.3) // Auto-trigger probability recalculation after result
try { try {
await updateProbabilitiesAfterResult(sportsSeasonId, true); await updateProbabilitiesAfterResult(sportsSeasonId, true);
console.log( console.log(
@ -641,6 +777,7 @@ export async function calculateTeamScore(
participant: { participant: {
with: { with: {
results: true, results: true,
sportsSeason: true,
}, },
}, },
}, },
@ -660,10 +797,15 @@ export async function calculateTeamScore(
let participantsCompleted = 0; let participantsCompleted = 0;
for (const pick of picks) { for (const pick of picks) {
const result = pick.participant.results[0]; // Should only be one result per participant // One result per participant per sports season (enforced by upsertParticipantResult).
// If duplicates exist due to data corruption, the first row wins — acceptable trade-off.
const result = pick.participant.results[0];
if (result && result.finalPosition) { if (result && result.finalPosition) {
const points = calculateFantasyPoints(result.finalPosition, scoringRules); const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
const points = isBracket
? calculateBracketPoints(result.finalPosition, scoringRules)
: calculateFantasyPoints(result.finalPosition, scoringRules);
totalPoints += points; totalPoints += points;
participantsCompleted++; participantsCompleted++;
@ -683,8 +825,8 @@ export async function calculateTeamScore(
} }
/** /**
* Calculate projected total points for a team * Calculate projected total points for a team.
* Phase 5.4: Includes actual points from finished participants + EVs from unfinished * Combines actual points from finished participants with EV projections for the rest.
*/ */
export async function calculateTeamProjectedScore( export async function calculateTeamProjectedScore(
teamId: string, teamId: string,
@ -861,10 +1003,9 @@ function assignRanks(
} }
/** /**
* Recalculate standings for all teams in a season * Recalculate standings for all teams in a season.
* Called after any scoring event completes or participant results change * Called after any scoring event completes or participant results change.
* * Implements tiebreaker logic: total points, then placement counts (1st, 2nd, ).
* Phase 4.1: Implements tiebreaker logic and ranking
*/ */
export async function recalculateStandings( export async function recalculateStandings(
seasonId: string, seasonId: string,
@ -923,7 +1064,6 @@ export async function recalculateStandings(
eighthPlaceCount: teamScore.placementCounts[8], eighthPlaceCount: teamScore.placementCounts[8],
participantsRemaining: participantsRemaining:
teamScore.participantsTotal - teamScore.participantsCompleted, teamScore.participantsTotal - teamScore.participantsCompleted,
// Phase 5.4: Projected points tracking
actualPoints: teamScore.actualPoints?.toString() ?? null, actualPoints: teamScore.actualPoints?.toString() ?? null,
projectedPoints: teamScore.projectedPoints?.toString() ?? null, projectedPoints: teamScore.projectedPoints?.toString() ?? null,
participantsFinished: teamScore.participantsFinished ?? null, participantsFinished: teamScore.participantsFinished ?? null,

View file

@ -104,6 +104,34 @@ export function calculateAveragedPoints(
return total / placements.length; return total / placements.length;
} }
/**
* Calculate fantasy points for a bracket placement, averaging tied positions.
*
* Standard single-elimination bracket tiers:
* 1st: solo winner
* 2nd: solo finalist
* 3rd-4th: two SF losers share these positions averaged
* 5th-8th: four QF losers share these positions averaged
*
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
*
* TODO: This hardcodes 8-team bracket tiers. Future support for larger brackets
* (e.g. 16-team with 9th-16th scoring) will require parameterised tier config.
*/
export function calculateBracketPoints(
finalPosition: number,
rules: ScoringRules
): number {
if (finalPosition <= 0) return 0;
if (finalPosition === 1) return rules.pointsFor1st;
if (finalPosition === 2) return rules.pointsFor2nd;
if (finalPosition === 3 || finalPosition === 4)
return calculateAveragedPoints([3, 4], rules);
if (finalPosition >= 5 && finalPosition <= 8)
return calculateAveragedPoints([5, 6, 7, 8], rules);
return 0;
}
/** /**
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th] * Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes * Useful for display purposes

View file

@ -124,6 +124,9 @@ export async function isUserAdmin(userId: string): Promise<boolean> {
} }
export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> { export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> {
if (process.env.NODE_ENV === "development" && process.env.DEV_ADMIN_CLERK_ID === clerkId) {
return true;
}
const user = await findUserByClerkId(clerkId); const user = await findUserByClerkId(clerkId);
return user?.isAdmin ?? false; return user?.isAdmin ?? false;
} }

View file

@ -5,10 +5,10 @@ import {
isUserLeagueMember, isUserLeagueMember,
isCommissioner, isCommissioner,
findTeamsBySeasonId, findTeamsBySeasonId,
findUserByClerkId,
} from "~/models"; } from "~/models";
import { getDraftPicks } from "~/models/draft-pick"; import { getDraftPicks } from "~/models/draft-pick";
import { getSeasonResults } from "~/models/participant-season-result"; import { getSeasonResults } from "~/models/participant-season-result";
import { calculateBracketPoints } from "~/models/scoring-rules";
import { getQPStandings } from "~/models/qualifying-points"; import { getQPStandings } from "~/models/qualifying-points";
import { import {
getUpcomingScoringEvents, getUpcomingScoringEvents,
@ -16,7 +16,7 @@ import {
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const { userId } = await getAuth(args);
@ -88,18 +88,17 @@ export async function loader(args: Route.LoaderArgs) {
{ teamName: string; teamId: string; ownerName?: string } { teamName: string; teamId: string; ownerName?: string }
>(); >();
// Get unique owner IDs for user lookups // 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))]; const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
const ownerUsers = await Promise.all( const ownerUserRows = ownerIds.length > 0
ownerIds.map(async (ownerId) => { ? await db.query.users.findMany({
const user = await findUserByClerkId(ownerId!); where: inArray(schema.users.clerkId, ownerIds),
return { columns: { clerkId: true, username: true, displayName: true },
ownerId,
name: user?.username || user?.displayName || "Unknown",
};
}) })
: [];
const ownerMap = new Map(
ownerUserRows.map((u) => [u.clerkId, u.username || u.displayName || "Unknown"])
); );
const ownerMap = new Map(ownerUsers.map((o) => [o.ownerId, o.name]));
// Map draft picks to ownership // Map draft picks to ownership
for (const pick of draftPicks) { for (const pick of draftPicks) {
@ -137,6 +136,7 @@ export async function loader(args: Route.LoaderArgs) {
let playoffRounds: string[] = []; let playoffRounds: string[] = [];
let preEliminatedParticipants: { id: string; name: string }[] = []; let preEliminatedParticipants: { id: string; name: string }[] = [];
let participantPoints: { participantId: string; points: number }[] = []; let participantPoints: { participantId: string; points: number }[] = [];
let partialScoreParticipantIds: string[] = [];
let seasonStandings: SeasonStanding[] = []; let seasonStandings: SeasonStanding[] = [];
let qpStandings: any[] = []; let qpStandings: any[] = [];
@ -169,8 +169,8 @@ export async function loader(args: Route.LoaderArgs) {
); );
} }
// Fetch group-stage losers and fantasy points in parallel // Fetch group-stage losers and all participant results for bracket scoring
const [eliminatedResults, seasonResults] = await Promise.all([ const [eliminatedResults, allResults] = await Promise.all([
db.query.participantResults.findMany({ db.query.participantResults.findMany({
where: and( where: and(
eq(schema.participantResults.sportsSeasonId, sportsSeasonId), eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
@ -178,16 +178,44 @@ export async function loader(args: Route.LoaderArgs) {
), ),
with: { participant: true }, with: { participant: true },
}), }),
getSeasonResults(sportsSeasonId), db.query.participantResults.findMany({
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
}),
]); ]);
preEliminatedParticipants = eliminatedResults preEliminatedParticipants = eliminatedResults
.filter((r) => r.participant) .filter((r) => r.participant)
.map((r) => ({ id: (r as any).participant.id, name: (r as any).participant.name })); .map((r) => ({ id: (r as any).participant.id, name: (r as any).participant.name }));
participantPoints = seasonResults // Compute per-participant fantasy points directly from participant_results.
.map((r) => ({ participantId: r.participantId, points: parseFloat(r.currentPoints || "0") })) // This covers both finalized placements and progressive floor scores for still-alive participants.
.filter((r) => r.points > 0); 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") { } else if (scoringPattern === "season_standings") {
// Fetch F1-style championship standings and map to SeasonStanding shape // Fetch F1-style championship standings and map to SeasonStanding shape
const results = await getSeasonResults(sportsSeasonId); const results = await getSeasonResults(sportsSeasonId);
@ -226,6 +254,7 @@ export async function loader(args: Route.LoaderArgs) {
playoffRounds, playoffRounds,
preEliminatedParticipants, preEliminatedParticipants,
participantPoints, participantPoints,
partialScoreParticipantIds,
seasonStandings, seasonStandings,
qpStandings, qpStandings,
teamOwnerships, teamOwnerships,

View file

@ -49,6 +49,7 @@ export default function SportSeasonDetail({
playoffRounds, playoffRounds,
preEliminatedParticipants, preEliminatedParticipants,
participantPoints, participantPoints,
partialScoreParticipantIds,
seasonStandings, seasonStandings,
qpStandings, qpStandings,
teamOwnerships, teamOwnerships,
@ -100,6 +101,7 @@ export default function SportSeasonDetail({
playoffRounds={playoffRounds} playoffRounds={playoffRounds}
preEliminatedParticipants={preEliminatedParticipants} preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints} participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings as any} seasonStandings={seasonStandings as any}
seasonIsFinalized={seasonIsFinalized} seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings as any} qpStandings={qpStandings as any}

View file

@ -32,6 +32,7 @@ describe("probability-updater", () => {
participantId: "participant-1", participantId: "participant-1",
sportsSeasonId: "season-1", sportsSeasonId: "season-1",
finalPosition: 1, finalPosition: 1,
isPartialScore: false,
qualifyingPoints: null, qualifyingPoints: null,
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
@ -102,6 +103,7 @@ describe("probability-updater", () => {
participantId: "participant-1", participantId: "participant-1",
sportsSeasonId: "season-1", sportsSeasonId: "season-1",
finalPosition: 1, finalPosition: 1,
isPartialScore: false,
qualifyingPoints: null, qualifyingPoints: null,
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
@ -112,6 +114,7 @@ describe("probability-updater", () => {
participantId: "participant-2", participantId: "participant-2",
sportsSeasonId: "season-1", sportsSeasonId: "season-1",
finalPosition: 2, finalPosition: 2,
isPartialScore: false,
qualifyingPoints: null, qualifyingPoints: null,
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
@ -187,6 +190,7 @@ describe("probability-updater", () => {
participantId: "participant-1", participantId: "participant-1",
sportsSeasonId: "season-1", sportsSeasonId: "season-1",
finalPosition: null, // No position set yet finalPosition: null, // No position set yet
isPartialScore: false,
qualifyingPoints: "50.00", qualifyingPoints: "50.00",
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),
@ -210,6 +214,7 @@ describe("probability-updater", () => {
participantId: "participant-1", participantId: "participant-1",
sportsSeasonId: "season-1", sportsSeasonId: "season-1",
finalPosition: 0, // Eliminated finalPosition: 0, // Eliminated
isPartialScore: false,
qualifyingPoints: null, qualifyingPoints: null,
notes: null, notes: null,
createdAt: new Date(), createdAt: new Date(),

View file

@ -318,6 +318,7 @@ export const participantResults = pgTable("participant_results", {
.notNull() .notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }), .references(() => sportsSeasons.id, { onDelete: "cascade" }),
finalPosition: integer("final_position"), finalPosition: integer("final_position"),
isPartialScore: boolean("is_partial_score").notNull().default(false),
qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }), qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1,2 @@
-- Progressive floor scoring: track whether a participant result is provisional (still alive)
ALTER TABLE "participant_results" ADD COLUMN "is_partial_score" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -267,6 +267,13 @@
"when": 1763289000000, "when": 1763289000000,
"tag": "0037_add_simulator_type_to_sports", "tag": "0037_add_simulator_type_to_sports",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1773122866361,
"tag": "0038_sad_wilson_fisk",
"breakpoints": true
} }
] ]
} }