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
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
it("splits a tied season_standings placement", async () => {
|
|
|
|
|
// processSeasonStandings writes the same finalPosition to every tied driver
|
|
|
|
|
// and leaves the split to scoring time. Two tied at 3rd share 3rd + 4th.
|
|
|
|
|
const db = makeDb(
|
|
|
|
|
[makePick("driver", "Tied Driver", "season_standings", 3)],
|
|
|
|
|
[{ sportsSeasonId: "ss-1", finalPosition: 3 }]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
|
|
|
|
|
|
|
|
|
|
expect(breakdown?.picks[0].points).toBe(45); // (50 + 40) / 2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("splits a four-way season_standings tie across the 5-8 tier", async () => {
|
|
|
|
|
const db = makeDb(
|
|
|
|
|
[makePick("driver", "Tied Driver", "season_standings", 5)],
|
|
|
|
|
[
|
|
|
|
|
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
|
|
|
|
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
|
|
|
|
{ sportsSeasonId: "ss-1", finalPosition: 5 },
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
|
|
|
|
|
|
|
|
|
|
expect(breakdown?.picks[0].points).toBe(20); // (25 + 25 + 15 + 15) / 4
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
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,
|
|
|
|
|
}),
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
makePick("driver", "Tied Driver", "season_standings", 3, {
|
|
|
|
|
sportsSeasonId: "ss-3",
|
|
|
|
|
pickNumber: 4,
|
|
|
|
|
}),
|
|
|
|
|
];
|
|
|
|
|
const undrafted = [
|
|
|
|
|
{ sportsSeasonId: "ss-1", finalPosition: 8 },
|
|
|
|
|
{ sportsSeasonId: "ss-3", finalPosition: 3 },
|
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
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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);
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
// 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3)
|
|
|
|
|
expect(breakdown?.actualPoints).toBe(173);
|
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
|
|
|
});
|
|
|
|
|
});
|