Compare commits

...

3 commits

Author SHA1 Message Date
Claude
9024391d2f
Add backfill for rounded tie splits and missing ledger rows
Two stored artifacts went stale when the tie-split math was unified.
team_standings totals hold pre-rounding values, and standings are only
rewritten when something re-triggers a recalculation, so already-finished
leagues would keep 217.50-style figures indefinitely. Separately,
qualifying_points and season_standings seasons finalized before this
change have no team_score_events rows, so their results stay missing from
Recent Scores.

The script re-ledgers every already-finalized one-shot sports season and
recalculates standings for every fantasy season. Both operations are pure
recomputes and ledger rows upsert on (team, season, scoring event), so it
is safe to re-run. Supports --dry, following backfill-qp-resplit.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:43:22 +00:00
Claude
2d571d8010
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.

Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.

The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.

Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
Claude
f3d63922d0
Unify tie-split point math across every screen
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
2026-08-07 07:29:37 +00:00
16 changed files with 1057 additions and 152 deletions

View file

@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import { computeCoronaStates } from "../corona-states";
const RULES = {
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function pick(id: string, scoringPattern: string | null, sportsSeasonId = "ss-1") {
return { participant: { id, sportsSeasonId }, scoringPattern };
}
function results(
rows: Array<{ participantId: string; finalPosition: number | null; isPartialScore?: boolean }>
) {
return new Map(
rows.map((r) => [r.participantId, { isPartialScore: false, ...r }])
);
}
describe("computeCoronaStates", () => {
const noTies = new Map<string, Map<number, number>>();
it("splits points for a tied qualifying_points pick", () => {
// Rahm tied for 8th. 9th is outside the scoring range and contributes 0,
// so (15 + 0) / 2 = 7.5 → 8. Before the shared helper this path awarded the
// full 15 while the standings awarded the split — the reported bug.
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 8 }]),
new Map(),
RULES,
RULES.pointsFor1st,
new Map([["ss-1", new Map([[8, 2]])]])
);
expect(states.rahm).toEqual({
type: "scored",
points: 8,
brightness: 8 / 100,
});
});
it("awards the full value when the placement is untied", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 8 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toMatchObject({ type: "scored", points: 15 });
});
it("still averages bracket tiers via the bracket template", () => {
const states = computeCoronaStates(
[pick("team-a", "playoff_bracket")],
results([{ participantId: "team-a", finalPosition: 5 }]),
new Map([["ss-1", null]]),
RULES,
RULES.pointsFor1st,
noTies
);
// Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20
expect(states["team-a"]).toMatchObject({ type: "scored", points: 20 });
});
it("scores other patterns straight off the placement", () => {
const states = computeCoronaStates(
[pick("driver", "season_standings")],
results([{ participantId: "driver", finalPosition: 2 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.driver).toMatchObject({ type: "scored", points: 70 });
});
it("marks picks with no result as pending", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toEqual({ type: "pending" });
});
it("marks a finalized zero placement as eliminated", () => {
const states = computeCoronaStates(
[pick("rahm", "qualifying_points")],
results([{ participantId: "rahm", finalPosition: 0 }]),
new Map(),
RULES,
RULES.pointsFor1st,
noTies
);
expect(states.rahm).toEqual({ type: "eliminated", points: 0 });
});
it("keeps brightness within range when a tier exceeds maxPoints", () => {
const states = computeCoronaStates(
[pick("champ", "season_standings")],
results([{ participantId: "champ", finalPosition: 1 }]),
new Map(),
RULES,
10, // deliberately below the 1st-place award
noTies
);
expect(states.champ).toMatchObject({ brightness: 1 });
});
});

View file

@ -1,4 +1,4 @@
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
import { calculatePickPoints } from "~/models/scoring-rules";
export type CoronaState =
| { type: "eliminated"; points: 0 }
@ -27,12 +27,22 @@ interface ScoringRules {
pointsFor8th: number;
}
/**
* Corona badge state for every pick on the draft board.
*
* @param sharedPlacementCountsBySportsSeason - sportsSeasonId (finalPosition
* how many participants share it), from getSharedPlacementCounts. Required for
* qualifying_points picks: a golfer tied for 8th earns the split award, and
* omitting this would show him at full value here while the standings show the
* split the exact inconsistency this parameter exists to prevent.
*/
export function computeCoronaStates(
picks: PickEntry[],
resultByParticipant: Map<string, ResultEntry>,
bracketTemplateBySportsSeason: Map<string, string | null>,
scoringRules: ScoringRules,
maxPoints: number,
sharedPlacementCountsBySportsSeason: Map<string, Map<number, number>>,
): Record<string, CoronaState> {
const coronaStates: Record<string, CoronaState> = {};
@ -50,13 +60,19 @@ export function computeCoronaStates(
}
if (result.finalPosition > 0) {
const isBracket = pick.scoringPattern === "playoff_bracket";
const templateId = isBracket
? (bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null)
: null;
const points = isBracket
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
: calculateFantasyPoints(result.finalPosition, scoringRules);
const points = calculatePickPoints(
result.finalPosition,
pick.scoringPattern,
scoringRules,
{
bracketTemplateId:
bracketTemplateBySportsSeason.get(pick.participant.sportsSeasonId) ?? null,
tiedParticipants:
sharedPlacementCountsBySportsSeason
.get(pick.participant.sportsSeasonId)
?.get(result.finalPosition) ?? 1,
}
);
const brightness = maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = { type: "scored", brightness, points };
continue;

View file

@ -204,7 +204,7 @@ describe("getDraftedParticipantsWithPoints", () => {
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(result.get("ss-1")?.[0]).toMatchObject({
earnedPoints: 62.5, // (75 + 50) / 2
earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63
currentQP: null,
});
});

View file

@ -71,8 +71,8 @@ describe("Scoring Calculator", () => {
it("should average points for three-way tie", () => {
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
// (100 + 70 + 50) / 3 = 73.33...
expect(points).toBeCloseTo(73.33, 2);
// (100 + 70 + 50) / 3 = 73.33... → 73 (nearest whole point)
expect(points).toBe(73);
});
it("should handle single placement (no tie)", () => {
@ -109,7 +109,18 @@ describe("Scoring Calculator", () => {
});
it("treats positions beyond 8th as zero at the scoring cutoff", () => {
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(7.5);
// Two tied for 8th share 8th + 9th; 9th is outside the scoring range and
// contributes 0, so (15 + 0) / 2 = 7.5 → 8.
expect(calculateSharedPlacementPoints(8, 2, DEFAULT_SCORING)).toBe(8);
});
// The /rules page (app/routes/rules.tsx) publishes these two examples to
// players. They are asserted here so the code and the published rule cannot
// drift apart — note the second rounds DOWN, confirming "nearest whole
// point" rather than always rounding up.
it("matches the tie examples published on the rules page", () => {
expect(calculateSharedPlacementPoints(2, 2, DEFAULT_SCORING)).toBe(60);
expect(calculateSharedPlacementPoints(6, 3, DEFAULT_SCORING)).toBe(18);
});
});
@ -150,8 +161,8 @@ describe("Scoring Calculator", () => {
describe("Edge Cases", () => {
it("should handle all participants tying for 1st-8th", () => {
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(points).toBe(42.5);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(points).toBe(43);
});
it("should handle placements with same point values", () => {

View file

@ -41,8 +41,8 @@ describe("Season Standings (F1 Pattern)", () => {
it("should handle 3-way tie for 5th place", () => {
// Three drivers tied for 5th share 5th, 6th, and 7th place points
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
// (25 + 25 + 15) / 3 = 21.67
expect(points).toBeCloseTo(21.67, 2);
// (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point)
expect(points).toBe(22);
});
it("should handle 4-way tie for 1st place", () => {
@ -100,7 +100,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Verify tied positions get averaged
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3
expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38
// Verify remaining positions
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
@ -143,7 +143,7 @@ describe("Season Standings (F1 Pattern)", () => {
// Tied positions
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
expect(tied3rd).toBe(67.5); // (75 + 60) / 2
expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up)
});
});
@ -151,8 +151,8 @@ describe("Season Standings (F1 Pattern)", () => {
it("should handle all 8 positions tied", () => {
// Extremely unlikely but mathematically possible
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(allTied).toBe(42.5);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(allTied).toBe(43);
});
it("should handle only top 4 finishing (others DNF/DQ)", () => {

View file

@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Regression tests for getTeamScoreBreakdown the team detail page's points.
*
* This path used to branch only on `isBracket`, so a qualifying_points
* participant tied for a placement was awarded the FULL placement value here
* while calculateTeamScore (which writes team_standings) awarded the split. A
* team therefore read 225 on its team page and 218 in the standings.
*
* The load-bearing assertion is the last one: the breakdown's actualPoints must
* equal calculateTeamScore's totalPoints for the same fixture. Anything that
* makes these two disagree reintroduces the bug.
*/
vi.mock("~/services/ev-calculator", () => ({
calculateEV: vi.fn(),
}));
vi.mock("../participant-expected-value", () => ({
getParticipantEV: vi.fn(),
}));
import { getTeamScoreBreakdown } from "../standings";
import { calculateTeamScore } from "../scoring-calculator";
import { getParticipantEV } from "../participant-expected-value";
const SCORING = {
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function makePick(
participantId: string,
name: string,
scoringPattern: string,
finalPosition: number | null,
{ sportsSeasonId = "ss-1", isPartialScore = false, pickNumber = 1 } = {}
) {
return {
pickNumber,
round: 1,
participant: {
id: participantId,
name,
sportsSeasonId,
results: finalPosition === null ? [] : [{ finalPosition, isPartialScore }],
sportsSeason: { scoringPattern, sport: { name: "Golf" } },
},
};
}
/**
* @param extraResults results for participants NOBODY drafted. They still count
* toward a tie span, which is exactly why the tie query cannot be narrowed to
* drafted participants.
*/
function makeDb(
picks: ReturnType<typeof makePick>[],
extraResults: { sportsSeasonId: string; finalPosition: number }[] = []
) {
const seasonResults = [
...picks.flatMap((pick) =>
pick.participant.results
.filter((r) => r.finalPosition !== null)
.map((r) => ({
sportsSeasonId: pick.participant.sportsSeasonId,
finalPosition: r.finalPosition,
}))
),
...extraResults,
];
return {
query: {
seasons: { findFirst: vi.fn().mockResolvedValue(SCORING) },
draftPicks: { findMany: vi.fn().mockResolvedValue(picks) },
scoringEvents: { findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }) },
seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(seasonResults) },
teams: { findFirst: vi.fn().mockResolvedValue({ id: "team-1", name: "Mike" }) },
},
} as any;
}
describe("getTeamScoreBreakdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getParticipantEV).mockResolvedValue(null as any);
});
it("splits a tied qualifying_points placement", async () => {
// Rahm tied for 8th with an undrafted player: (15 + 0) / 2 = 7.5 → 8.
const db = makeDb(
[makePick("rahm", "Jon Rahm", "qualifying_points", 8)],
[{ sportsSeasonId: "ss-1", finalPosition: 8 }]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({
participant: expect.objectContaining({ name: "Jon Rahm" }),
finalPosition: 8,
points: 8,
});
expect(breakdown?.actualPoints).toBe(8);
});
it("awards the full placement value when untied", async () => {
const db = makeDb([makePick("rahm", "Jon Rahm", "qualifying_points", 8)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(15);
expect(breakdown?.actualPoints).toBe(15);
});
it("counts undrafted participants in the tie span", async () => {
// Three-way tie for 6th where two of the three went undrafted:
// (25 + 15 + 15) / 3 = 18.33 → 18, the rules page's published example.
const db = makeDb(
[makePick("golfer", "Tied Golfer", "qualifying_points", 6)],
[
{ sportsSeasonId: "ss-1", finalPosition: 6 },
{ sportsSeasonId: "ss-1", finalPosition: 6 },
]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(18);
});
it("still averages bracket tiers", async () => {
const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
// Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20
expect(breakdown?.picks[0].points).toBe(20);
});
it("leaves picks without a result at zero points", async () => {
const db = makeDb([makePick("pending", "Pending Golfer", "qualifying_points", null)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({ points: 0, isComplete: false });
expect(breakdown?.actualPoints).toBe(0);
});
it("agrees with calculateTeamScore on the same roster", async () => {
// The reported bug in miniature: a tied golfer plus untied picks. Both
// functions read the same fixture, so any divergence is a real regression.
const roster = () => [
makePick("rahm", "Jon Rahm", "qualifying_points", 8, { pickNumber: 1 }),
makePick("scheffler", "Scottie Scheffler", "qualifying_points", 1, { pickNumber: 2 }),
makePick("team-a", "Team A", "playoff_bracket", 5, {
sportsSeasonId: "ss-2",
pickNumber: 3,
}),
];
const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }];
const breakdown = await getTeamScoreBreakdown(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
const score = await calculateTeamScore(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
expect(breakdown?.actualPoints).toBe(score.totalPoints);
expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8)
});
});

View file

@ -344,7 +344,7 @@ describe("calculateTeamScore", () => {
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2
expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8
expect(result.participantsCompleted).toBe(1);
expect(result.placementCounts[8]).toBe(1);
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn() },
logger: { error: vi.fn(), warn: vi.fn() },
}));
// ── DB mock helpers ────────────────────────────────────────────────────────
@ -16,7 +16,13 @@ function makeInsertChain() {
interface MakeDbOpts {
sportsSeason?: { sport: { name: string } } | null;
seasonSports?: { seasonId: string }[];
picks?: { teamId: string; seasonId: string }[];
picks?: { teamId: string; seasonId: string; participantId?: string }[];
scoringEvent?: { id: string; name: string } | null;
participantResults?: {
participantId: string;
finalPosition: number | null;
sportsSeasonId?: string;
}[];
seasons?: {
id: string;
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
@ -41,6 +47,8 @@ function makeDb(opts: MakeDbOpts = {}) {
seasons = [],
scoreEventRows = [],
participantRows = [],
scoringEvent = null,
participantResults = [],
} = opts;
const chain = makeInsertChain();
@ -67,13 +75,24 @@ function makeDb(opts: MakeDbOpts = {}) {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(participantRows),
},
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(scoringEvent),
},
seasonParticipantResults: {
findMany: vi.fn().mockResolvedValue(participantResults),
},
},
} as any,
chain,
};
}
import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events";
import {
recordTeamScoreEvent,
recordMatchScoreEvents,
recordFinalPlacementScoreEvents,
getRecentTeamScoreEvents,
} from "../team-score-events";
const BASE_PARAMS = {
teamId: "team-1",
@ -358,3 +377,155 @@ describe("getRecentTeamScoreEvents", () => {
expect(result[0].participants).toEqual([]);
});
});
describe("recordFinalPlacementScoreEvents", () => {
beforeEach(() => vi.clearAllMocks());
const RULES = {
id: "season-1",
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function makeQpDb(overrides: Partial<MakeDbOpts> = {}) {
return makeDb({
sportsSeason: { scoringPattern: "qualifying_points", sport: { name: "Golf" } } as any,
scoringEvent: { id: "event-9", name: "The Open" },
seasonSports: [{ seasonId: "season-1" }],
seasons: [RULES],
...overrides,
// Tie counts are grouped by sportsSeasonId, so result rows must carry it.
participantResults: (overrides.participantResults ?? []).map((r) => ({
sportsSeasonId: "ss-1",
...r,
})),
});
}
it("writes one row per team with the summed award and its participants", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "scheffler", finalPosition: 1 },
],
picks: [
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
{ teamId: "team-1", seasonId: "season-1", participantId: "scheffler" },
],
});
await recordFinalPlacementScoreEvents(
{ sportsSeasonId: "ss-1", eventName: "Final Standings" },
db
);
expect(chain.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
teamId: "team-1",
seasonId: "season-1",
scoringEventId: "event-9",
scoringEventName: "Final Standings",
sportName: "Golf",
matchId: null,
pointsDelta: "115", // 100 (1st) + 15 (8th, untied)
participantIds: ["rahm", "scheffler"],
})
);
});
it("awards the split value when the placement is tied", async () => {
const { db, chain } = makeQpDb({
// Rahm ties for 8th with an UNDRAFTED player — the tie still halves it.
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "undrafted-guy", finalPosition: 8 },
],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
// (15 + 0) / 2 = 7.5 → 8, matching what the standings show.
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ pointsDelta: "8", participantIds: ["rahm"] })
);
});
it("keeps each team's award separate", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 8 },
{ participantId: "scheffler", finalPosition: 1 },
],
picks: [
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
{ teamId: "team-2", seasonId: "season-1", participantId: "scheffler" },
],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).toHaveBeenCalledTimes(2);
const deltasByTeam = Object.fromEntries(
chain.values.mock.calls.map(([v]: any[]) => [v.teamId, v.pointsDelta])
);
expect(deltasByTeam).toEqual({ "team-1": "15", "team-2": "100" });
});
it("upserts on the event-level target so re-finalizing does not duplicate", async () => {
const { db, chain } = makeQpDb({
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
expect(conflictArg.target).toHaveLength(3);
expect(conflictArg.targetWhere).toBeDefined();
expect(conflictArg.set).toMatchObject({ pointsDelta: "15" });
});
it("skips the write when no scoring event can anchor the row", async () => {
// A null scoringEventId would defeat the partial unique index, since
// Postgres treats NULLs as distinct — better to skip than duplicate.
const { db, chain } = makeQpDb({
scoringEvent: null,
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("ignores non-scoring placements", async () => {
const { db, chain } = makeQpDb({
participantResults: [
{ participantId: "rahm", finalPosition: 0 },
{ participantId: "other", finalPosition: null },
],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.insert).not.toHaveBeenCalled();
});
it("scores season_standings placements with the shared tier averaging", async () => {
const { db, chain } = makeQpDb({
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
participantResults: [{ participantId: "driver", finalPosition: 3 }],
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
});
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ sportName: "F1", pointsDelta: "50" })
);
});
});

View file

@ -1,12 +1,11 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray, asc } from "drizzle-orm";
import { getScoringRules, calculatePickPoints } from "./scoring-rules";
import {
getScoringRules,
calculateFantasyPoints,
calculateBracketPoints,
calculateSharedPlacementPoints,
} from "./scoring-rules";
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "./participant-result";
export async function createDraftPick(data: {
seasonId: string;
@ -203,19 +202,10 @@ export async function getDraftedParticipantsWithPoints(
}
}
const qpSharedPlacementCounts = new Map<string, Map<number, number>>();
if (finalizedQPSeasonIds.size > 0) {
const results = await db.query.seasonParticipantResults.findMany({
where: inArray(schema.seasonParticipantResults.sportsSeasonId, [...finalizedQPSeasonIds]),
columns: { sportsSeasonId: true, finalPosition: true },
});
for (const row of results) {
if (row.finalPosition === null || row.finalPosition <= 0) continue;
const counts = qpSharedPlacementCounts.get(row.sportsSeasonId) ?? new Map<number, number>();
qpSharedPlacementCounts.set(row.sportsSeasonId, counts);
counts.set(row.finalPosition, (counts.get(row.finalPosition) ?? 0) + 1);
}
}
const qpSharedPlacementCounts = await getSharedPlacementCounts(
[...finalizedQPSeasonIds],
db
);
// Assemble result grouped by sportsSeasonId
const result = new Map<string, DraftedParticipantWithPoints[]>();
@ -233,23 +223,14 @@ export async function getDraftedParticipantsWithPoints(
currentQP = qpMap.get(id) ?? null;
} else if (resultRow?.finalPosition !== null && resultRow?.finalPosition !== undefined) {
// Finalized result for any pattern (including finalized QP seasons)
if (pattern === "playoff_bracket") {
earnedPoints = calculateBracketPoints(
resultRow.finalPosition,
scoringRules,
bracketTemplateMap.get(sportsSeasonId) ?? null
);
} else if (pattern === "qualifying_points") {
const tiedParticipants =
qpSharedPlacementCounts.get(sportsSeasonId)?.get(resultRow.finalPosition) ?? 1;
earnedPoints = calculateSharedPlacementPoints(
resultRow.finalPosition,
tiedParticipants,
scoringRules
);
} else {
earnedPoints = calculateFantasyPoints(resultRow.finalPosition, scoringRules);
}
earnedPoints = calculatePickPoints(resultRow.finalPosition, pattern, scoringRules, {
bracketTemplateId: bracketTemplateMap.get(sportsSeasonId) ?? null,
tiedParticipants: lookupSharedPlacementCount(
qpSharedPlacementCounts,
sportsSeasonId,
resultRow.finalPosition
),
});
}
const arr = result.get(sportsSeasonId) ?? [];

View file

@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -76,6 +76,57 @@ export async function findParticipantResultsBySportsSeasonId(
});
}
/**
* How many participants share each scoring placement, per sports season.
*
* Returns sportsSeasonId (finalPosition count). Used to split a tied
* placement's points across the tied participants (see calculatePickPoints).
*
* The count spans EVERY result in the sports season, not just drafted ones a
* golfer tied for 8th with an undrafted player still only earns half the 8th
* place points, so narrowing this query to drafted participants would silently
* over-award. Positions <= 0 (no scoring placement) are excluded.
*
* Callers that look up a position with no entry should treat it as 1 (no tie).
*/
export async function getSharedPlacementCounts(
sportsSeasonIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<Map<string, Map<number, number>>> {
const counts = new Map<string, Map<number, number>>();
if (sportsSeasonIds.length === 0) return counts;
const db = providedDb || database();
const rows = await db.query.seasonParticipantResults.findMany({
where: inArray(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonIds),
columns: { sportsSeasonId: true, finalPosition: true },
});
for (const row of rows) {
if (row.finalPosition === null || row.finalPosition <= 0) continue;
let bySeason = counts.get(row.sportsSeasonId);
if (!bySeason) {
bySeason = new Map<number, number>();
counts.set(row.sportsSeasonId, bySeason);
}
bySeason.set(row.finalPosition, (bySeason.get(row.finalPosition) ?? 0) + 1);
}
return counts;
}
/**
* Convenience lookup over getSharedPlacementCounts' result. Missing entries mean
* no other participant shares the placement, so the tie count is 1.
*/
export function lookupSharedPlacementCount(
counts: Map<string, Map<number, number>>,
sportsSeasonId: string,
finalPosition: number
): number {
return counts.get(sportsSeasonId)?.get(finalPosition) ?? 1;
}
export async function updateParticipantResult(
id: string,
data: Partial<NewParticipantResult>

View file

@ -1,12 +1,8 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import {
getScoringRules,
calculateFantasyPoints,
calculateBracketPoints,
calculateSharedPlacementPoints,
} from "./scoring-rules";
import { getScoringRules, calculatePickPoints, type ScoringRules } from "./scoring-rules";
import { getSharedPlacementCounts } from "./participant-result";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
@ -16,7 +12,10 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import {
recordMatchScoreEvents,
recordFinalPlacementScoreEvents,
} from "~/models/team-score-events";
import { logger } from "~/lib/logger";
import { getEventResults } from "./event-result";
import {
@ -595,6 +594,11 @@ async function upsertParticipantResult(
}
}
/**
* Lazily populates `cache` (sportsSeasonId placement count) one sports
* season at a time, then reads the tie count for `finalPosition`. The counting
* itself lives in getSharedPlacementCounts so every screen shares one definition.
*/
async function getSharedPlacementCount(
sportsSeasonId: string,
finalPosition: number,
@ -602,17 +606,8 @@ async function getSharedPlacementCount(
cache: Map<string, Map<number, number>>
): Promise<number> {
if (!cache.has(sportsSeasonId)) {
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
columns: { finalPosition: true },
});
const counts = new Map<number, number>();
for (const result of results) {
if (result.finalPosition !== null && result.finalPosition > 0) {
counts.set(result.finalPosition, (counts.get(result.finalPosition) ?? 0) + 1);
}
}
cache.set(sportsSeasonId, counts);
const counts = await getSharedPlacementCounts([sportsSeasonId], db);
cache.set(sportsSeasonId, counts.get(sportsSeasonId) ?? new Map<number, number>());
}
return cache.get(sportsSeasonId)?.get(finalPosition) ?? 1;
@ -1178,6 +1173,13 @@ export async function finalizeQualifyingPoints(
})
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
// Ledger the placement points so this season shows up in Recent Scores —
// QP seasons award everything here, with no per-match deltas to record.
await recordFinalPlacementScoreEvents(
{ sportsSeasonId, eventName: "Final Standings" },
db
);
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
@ -1281,6 +1283,13 @@ export async function processSeasonStandings(
}
}
// Ledger the placement points — season_standings has the same one-shot award
// shape as qualifying_points and was likewise absent from Recent Scores.
await recordFinalPlacementScoreEvents(
{ sportsSeasonId, eventName: "Season Complete" },
db
);
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
@ -1376,27 +1385,22 @@ export async function calculateTeamScore(
const result = pick.participant.results[0];
if (result && result.finalPosition !== null && result.finalPosition > 0) {
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
const pattern = pick.participant.sportsSeason?.scoringPattern;
const points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
bracketTemplateId:
pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId)
: null,
tiedParticipants:
pattern === "qualifying_points"
? await getSharedPlacementCount(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
)
: 1,
});
totalPoints += points;
// All participants with a valid position count toward the placement tiebreaker,
@ -1483,57 +1487,52 @@ export async function calculateTeamProjectedScore(
return templateId;
}
// Points for one pick's placement. Identical for finalized results and for the
// provisional floor of a still-alive participant — only what we do with the
// number afterwards differs. `rules` re-binds the null-checked scoringRules
// because the hoisted declaration below cannot see that narrowing.
const rules: ScoringRules = scoringRules;
async function pointsForPick(
sportsSeasonId: string,
pattern: string | null | undefined,
finalPosition: number
): Promise<number> {
return calculatePickPoints(finalPosition, pattern, rules, {
bracketTemplateId:
pattern === "playoff_bracket" ? await getBracketTemplate(sportsSeasonId) : null,
tiedParticipants:
pattern === "qualifying_points"
? await getSharedPlacementCount(
sportsSeasonId,
finalPosition,
db,
sharedPlacementCountCache
)
: 1,
});
}
// Separate finished vs unfinished participants
for (const pick of picks) {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
const isQualifyingPoints = pick.participant.sportsSeason?.scoringPattern === "qualifying_points";
const pattern = pick.participant.sportsSeason?.scoringPattern;
if (result && result.finalPosition !== null && !result.isPartialScore) {
// Participant is fully finalized — use bracket-averaged points
let points: number;
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
// Participant is fully finalized
actualPoints += await pointsForPick(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
pattern,
result.finalPosition
);
points = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += points;
participantsFinished++;
} else if (result && result.finalPosition !== null && result.isPartialScore) {
// Still alive with a provisional floor — count floor as actual, EV for projection.
// Note: NOT incremented in participantsFinished; these participants are still competing.
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
let floorPoints: number;
if (isBracket) {
floorPoints = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else if (isQualifyingPoints) {
const tiedParticipants = await getSharedPlacementCount(
const floorPoints = await pointsForPick(
pick.participant.sportsSeasonId,
result.finalPosition,
db,
sharedPlacementCountCache
pattern,
result.finalPosition
);
floorPoints = calculateSharedPlacementPoints(
result.finalPosition,
tiedParticipants,
scoringRules
);
} else {
floorPoints = calculateFantasyPoints(result.finalPosition, scoringRules);
}
actualPoints += floorPoints;
// EV already accounts for their full projected value, so subtract floor to avoid
// double-counting when we do actualPoints + evSum below

View file

@ -90,6 +90,13 @@ export function calculateFantasyPoints(
*
* Example: 4 teams lose in quarterfinals, they share positions 5-8
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
*
* The result is rounded to the nearest whole point, matching the published rule
* on the /rules page: "the points for all tied positions are combined and split
* equally among them, rounded to the nearest whole point". That page's own
* example rounds down a three-way tie for 6th8th is (25 + 15 + 15) / 3 =
* 18.33 18 so this is nearest, not ceiling. Season point values are integer
* columns, so this averaging is the only place fractional points can arise.
*/
export function calculateAveragedPoints(
placements: number[],
@ -101,7 +108,9 @@ export function calculateAveragedPoints(
return sum + calculateFantasyPoints(placement, rules);
}, 0);
return total / placements.length;
// Epsilon guard mirrors roundQualifyingPoints — keeps values that are exactly
// representable-adjacent (e.g. 18.499999999999996) from rounding the wrong way.
return Math.round(total / placements.length + Number.EPSILON);
}
/**
@ -190,6 +199,43 @@ export function calculateBracketPoints(
return 0;
}
/**
* Fantasy points earned by a single drafted participant, for any scoring pattern.
*
* This is the ONE place the bracket / qualifying_points / default cascade lives.
* It previously existed as a hand-rolled if/else at six call sites, two of which
* were missing the qualifying_points arm entirely that divergence is what made
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
* standings. Every caller must route through here.
*
* @param finalPosition - Placement in the sports season (1-8 scores, 0 = none).
* @param scoringPattern - The sports season's scoringPattern column.
* @param rules - The fantasy season's point values.
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
* tier structure (e.g. AFL/LLWS split 58 into two pairs).
* @param opts.tiedParticipants - Required for qualifying_points: how many
* participants share this finalPosition across the WHOLE sports season, not
* just the ones that were drafted. Defaults to 1 (no tie).
*/
export function calculatePickPoints(
finalPosition: number,
scoringPattern: string | null | undefined,
rules: ScoringRules,
opts?: { bracketTemplateId?: string | null; tiedParticipants?: number }
): number {
if (scoringPattern === "playoff_bracket") {
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
}
if (scoringPattern === "qualifying_points") {
return calculateSharedPlacementPoints(
finalPosition,
opts?.tiedParticipants ?? 1,
rules
);
}
return calculateFantasyPoints(finalPosition, rules);
}
/**
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes

View file

@ -2,7 +2,11 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from "~/types/standings";
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
import { calculatePickPoints } from "~/models/scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { logger } from "~/lib/logger";
import { getParticipantEV } from "./participant-expected-value";
import { calculateEV } from "~/services/ev-calculator";
@ -157,6 +161,14 @@ export async function getTeamScoreBreakdown(
pointsFor8th: season.pointsFor8th,
};
// Tie counts for qualifying_points picks, so a golfer tied for 8th is worth the
// split award here exactly as it is in calculateTeamScore. Counted across every
// participant in the sports season — an undrafted tie partner still halves it.
const sharedPlacementCounts = await getSharedPlacementCounts(
[...new Set(picks.map((p) => p.participant.sportsSeasonId))],
db
);
// Cache bracket template IDs per sports season (same approach as calculateTeamScore)
const bracketTemplateCache = new Map<string, string | null>();
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
@ -176,7 +188,7 @@ export async function getTeamScoreBreakdown(
const pickBreakdown = await Promise.all(
picks.map(async (pick) => {
const result = pick.participant.results[0];
const isBracket = pick.participant.sportsSeason.scoringPattern === "playoff_bracket";
const pattern = pick.participant.sportsSeason.scoringPattern;
let points = 0;
let projectedPoints: number | null = null;
@ -199,13 +211,17 @@ export async function getTeamScoreBreakdown(
};
if (result && result.finalPosition !== null && result.finalPosition > 0) {
// Calculate points using bracket-averaged scoring for bracket sports
if (isBracket) {
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
} else {
points = calculateFantasyPoints(result.finalPosition, scoringRules);
}
points = calculatePickPoints(result.finalPosition, pattern, scoringRules, {
bracketTemplateId:
pattern === "playoff_bracket"
? await getBracketTemplate(pick.participant.sportsSeasonId)
: null,
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
pick.participant.sportsSeasonId,
result.finalPosition
),
});
if (result.isPartialScore) {
// Still alive with a floor position — use EV for projected since they can advance

View file

@ -1,7 +1,15 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray, desc, sql, and } from "drizzle-orm";
import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules";
import {
calculateBracketPoints,
calculatePickPoints,
type ScoringRules,
} from "~/models/scoring-rules";
import {
getSharedPlacementCounts,
lookupSharedPlacementCount,
} from "~/models/participant-result";
import { findParticipantNamesByIds } from "~/models/season-participant";
import { logger } from "~/lib/logger";
@ -190,6 +198,174 @@ export async function recordMatchScoreEvents(
}
}
/**
* Records ledger rows for a sports season whose final placements have just been
* assigned qualifying_points (golf, tennis, CS2) and season_standings (F1).
*
* These patterns award all of their points in one step at finalization, so they
* have no per-match deltas and were never written to team_score_events at all:
* recordMatchScoreEvents is bracket-only and fires from match processing. The
* effect was that golf results silently never appeared in Recent Scores.
*
* One row per team (matching the event-level unique index), carrying that team's
* summed award and every participant that contributed. Points come from
* calculatePickPoints, so a tied golfer contributes the same split award the
* standings show.
*
* `eventId` anchors the row and MUST resolve to a real scoring event: the
* event-level unique index is (teamId, seasonId, scoringEventId) and Postgres
* treats NULLs as distinct, so a null anchor would silently duplicate rows on
* every re-finalization instead of upserting. If no anchor can be found the
* ledger write is skipped rather than risking duplicates standings are
* unaffected either way.
*/
export async function recordFinalPlacementScoreEvents(
params: {
sportsSeasonId: string;
eventId?: string | null;
eventName?: string | null;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
columns: { id: true, scoringPattern: true },
with: { sport: { columns: { name: true } } },
});
if (!sportsSeason) return;
// Resolve the anchor event: the caller's, else the most recently completed
// event for this sports season.
let eventId = params.eventId ?? null;
let eventName = params.eventName ?? null;
if (!eventId) {
const anchor = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
columns: { id: true, name: true },
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)],
});
if (!anchor) {
logger.warn(
`[TeamScoreEvents] No scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
);
return;
}
eventId = anchor.id;
eventName = eventName ?? anchor.name;
}
if (!eventId) return;
// Scoring placements for this sports season, plus the tie spans they imply.
const results = await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
columns: { participantId: true, finalPosition: true },
});
const positionByParticipantId = new Map<string, number>();
for (const row of results) {
if (row.finalPosition !== null && row.finalPosition > 0) {
positionByParticipantId.set(row.participantId, row.finalPosition);
}
}
if (positionByParticipantId.size === 0) return;
const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db);
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
columns: { seasonId: true },
});
if (seasonSports.length === 0) return;
const seasonIds = seasonSports.map((ss) => ss.seasonId);
const picks = await db.query.draftPicks.findMany({
where: and(
inArray(schema.draftPicks.seasonId, seasonIds),
inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()])
),
columns: { teamId: true, seasonId: true, participantId: true },
});
if (picks.length === 0) return;
const seasonRows = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
columns: {
id: true,
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
pointsFor7th: true, pointsFor8th: true,
},
});
const rulesBySeasonId = new Map<string, ScoringRules>(
seasonRows.map((s) => [s.id, {
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
}])
);
// Accumulate per (season, team) — the ledger's event-level grain.
const byTeam = new Map<
string,
{ teamId: string; seasonId: string; points: number; participantIds: string[] }
>();
for (const pick of picks) {
const rules = rulesBySeasonId.get(pick.seasonId);
const finalPosition = positionByParticipantId.get(pick.participantId);
if (!rules || finalPosition === undefined) continue;
const points = calculatePickPoints(
finalPosition,
sportsSeason.scoringPattern,
rules,
{
tiedParticipants: lookupSharedPlacementCount(
sharedPlacementCounts,
params.sportsSeasonId,
finalPosition
),
}
);
if (points <= 0) continue;
const key = `${pick.seasonId}:${pick.teamId}`;
const entry = byTeam.get(key) ?? {
teamId: pick.teamId,
seasonId: pick.seasonId,
points: 0,
participantIds: [],
};
entry.points += points;
entry.participantIds.push(pick.participantId);
byTeam.set(key, entry);
}
for (const entry of byTeam.values()) {
try {
await recordTeamScoreEvent(
{
teamId: entry.teamId,
seasonId: entry.seasonId,
scoringEventId: eventId,
scoringEventName: eventName,
sportName: sportsSeason.sport?.name ?? null,
participantIds: entry.participantIds,
pointsDelta: entry.points,
},
db
);
} catch (err) {
logger.error(
`[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`,
err
);
}
}
}
export interface TeamScoreEventEntry {
id: string;
teamId: string;

View file

@ -12,6 +12,7 @@ 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";
@ -157,12 +158,19 @@ export async function loader(args: Route.LoaderArgs) {
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,
);
}

View file

@ -0,0 +1,130 @@
/**
* Backfill: recalculate standings after tie splits became whole points, and
* ledger the final placements that were never recorded.
*
* Two stored artifacts went stale when the tie-split math was unified:
*
* 1. team_standings.total_points / actual_points / projected_points hold
* pre-rounding values (a team carrying a golfer tied for 8th sits at
* 217.50 rather than 218). Standings are only rewritten when something
* re-triggers a recalculation, so leagues whose seasons already finished
* would keep the old figures indefinitely.
*
* 2. qualifying_points and season_standings sports were never written to
* team_score_events at all, so their results are missing from Recent
* Scores. recordFinalPlacementScoreEvents now writes them at
* finalization, but only for seasons finalized from here on.
*
* This re-runs both for every already-finalized sports season. Ledger rows are
* upserted on (team, season, scoring event), so re-running rewrites rather than
* duplicates. Standings recalculation is likewise a pure recompute from
* participant results.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-rounded-tie-splits.ts # apply
* npx tsx scripts/backfill-rounded-tie-splits.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { inArray } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { recalculateStandings } from "../app/models/scoring-calculator.js";
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
/** Patterns that award all of their points at once, with no per-match deltas. */
type ScoringPattern = NonNullable<
(typeof schema.sportsSeasons.$inferSelect)["scoringPattern"]
>;
const ONE_SHOT_PATTERNS: ScoringPattern[] = ["qualifying_points", "season_standings"];
async function run() {
const db = database();
// Ledger pass: one-shot sports seasons that already have final placements.
const oneShotSeasons = await db.query.sportsSeasons.findMany({
where: inArray(schema.sportsSeasons.scoringPattern, ONE_SHOT_PATTERNS),
columns: { id: true, name: true, scoringPattern: true },
});
const finalized: typeof oneShotSeasons = [];
for (const ss of oneShotSeasons) {
const anyPlacement = await db.query.seasonParticipantResults.findFirst({
where: (r, { eq, and, gt }) =>
and(eq(r.sportsSeasonId, ss.id), gt(r.finalPosition, 0)),
columns: { id: true },
});
if (anyPlacement) finalized.push(ss);
}
log(
`One-shot sports seasons with final placements: ${finalized.length} of ${oneShotSeasons.length}`
);
let ledgered = 0;
let ledgerFailed = 0;
for (const ss of finalized) {
if (DRY) {
log(` (dry) ${ss.name} [${ss.scoringPattern}] — would write ledger rows`);
continue;
}
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId: ss.id }, db);
ledgered += 1;
log(` ${ss.name} [${ss.scoringPattern}]: ledgered`);
} catch (e) {
ledgerFailed += 1;
log(` ! ${ss.name}: ${(e as Error).message}`);
}
}
// Standings pass: every fantasy season, so rounded awards land in stored
// totals. Cheap enough to run unconditionally and avoids trying to guess
// which seasons contain a tie.
const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } });
log(`\nFantasy seasons to recalculate: ${seasons.length}`);
let recalculated = 0;
let recalcFailed = 0;
for (const season of seasons) {
if (DRY) continue;
try {
await recalculateStandings(season.id, db);
recalculated += 1;
} catch (e) {
recalcFailed += 1;
log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`);
}
}
log(
`\nDone${DRY ? " (dry run — no writes)" : ""}. ` +
`ledgered=${ledgered} (failed ${ledgerFailed}), ` +
`standings recalculated=${recalculated} (failed ${recalcFailed}).`
);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});