brackt/app/models/__tests__/team-projected-score.test.ts

352 lines
13 KiB
TypeScript
Raw Normal View History

Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Tests for calculateTeamProjectedScore
*
* Key behaviours under test:
* - Finalized bracket participants use calculateBracketPoints (averaged), not raw points
* - Partial-score participants (still alive) contribute floor points to actualPoints
* and their incremental EV (EV - floor) to projectedPoints
* - EV < floor is clamped to 0 extra EV (Math.max guard)
* - Non-bracket finalized participants use calculateFantasyPoints (raw)
* - Pending participants (no result) contribute full EV to projectedPoints
*/
vi.mock("~/services/ev-calculator", () => ({
calculateEV: vi.fn(),
}));
vi.mock("../participant-expected-value", () => ({
getParticipantEV: vi.fn(),
}));
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
import { calculateTeamProjectedScore, calculateTeamScore } from "../scoring-calculator";
Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
import { calculateEV } from "~/services/ev-calculator";
import { getParticipantEV } from "../participant-expected-value";
// Standard scoring rules used across tests
const SCORING = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
// Derived bracket averages for assertions
const AVG_5_TO_8 = (25 + 25 + 15 + 15) / 4; // 20
const AVG_3_TO_4 = (50 + 40) / 2; // 45
/** Build a minimal pick object matching what the query returns */
function makePick(
participantId: string,
sportsSeasonId: string,
scoringPattern: string,
result: { finalPosition: number | null; isPartialScore: boolean } | null
) {
return {
pickNumber: 1,
round: 1,
participant: {
id: participantId,
sportsSeasonId,
results: result ? [result] : [],
sportsSeason: { scoringPattern },
},
};
}
/** Build a mock DB that returns the given picks and no bracketTemplateId */
function makeDb(
picks: ReturnType<typeof makePick>[],
seasonResults = picks.flatMap((pick) =>
pick.participant.results
.filter((result) => result.finalPosition !== null)
.map((result) => ({
sportsSeasonId: pick.participant.sportsSeasonId,
finalPosition: result.finalPosition,
}))
)
) {
Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
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),
},
Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
},
} as any;
}
const DUMMY_EV_ROW = {
probFirst: "0.1",
probSecond: "0.1",
probThird: "0.1",
probFourth: "0.1",
probFifth: "0.1",
probSixth: "0.1",
probSeventh: "0.2",
probEighth: "0.2",
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
describe("calculateTeamProjectedScore", () => {
describe("finalized bracket participant", () => {
it("uses bracket-averaged points, not raw position value", async () => {
// QF loser: finalPosition=5, isPartialScore=false
// calculateBracketPoints(5) = avg(5,6,7,8) = 20, NOT raw pointsFor5th = 25
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(null);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_5_TO_8); // 20, not 25
expect(result.projectedPoints).toBe(AVG_5_TO_8);
expect(result.participantsFinished).toBe(1);
expect(result.participantsRemaining).toBe(0);
});
it("averages 3rd/4th for bracket semi-final losers", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: false }),
]);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_3_TO_4); // 45
expect(result.projectedPoints).toBe(AVG_3_TO_4);
});
it("uses raw points for non-bracket (e.g. season_standings) finalized participant", async () => {
// Non-bracket 3rd place should use pointsFor3rd = 50, not averaged
const db = makeDb([
makePick("p1", "ss1", "season_standings", { finalPosition: 3, isPartialScore: false }),
]);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(50); // raw pointsFor3rd
});
it("splits finalized QP tie points by shared fantasy slots", async () => {
const db = makeDb(
[
makePick("p1", "ss1", "qualifying_points", { finalPosition: 2, isPartialScore: false }),
],
[
{ sportsSeasonId: "ss1", finalPosition: 2 },
{ sportsSeasonId: "ss1", finalPosition: 2 },
]
);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(60); // (70 + 50) / 2
expect(result.projectedPoints).toBe(60);
});
Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
});
describe("partial-score bracket participant (still alive)", () => {
it("floor counts as actual; incremental EV above floor counts toward projected", async () => {
// Won QF: finalPosition=3 (floor = avg[3,4] = 45), isPartialScore=true
// EV = 60 → incremental = 60 - 45 = 15 → projected = 45 + 15 = 60
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(60);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_3_TO_4); // 45 floor
expect(result.projectedPoints).toBe(60); // 45 + (60 - 45)
// Partial participants are NOT counted as finished — they're still competing
expect(result.participantsFinished).toBe(0);
expect(result.participantsRemaining).toBe(1);
});
it("clamps EV contribution to 0 when EV drops below floor (Math.max guard)", async () => {
// Won SF: finalPosition=2 (floor = 70), isPartialScore=true
// EV = 50 (below floor) → incremental = max(0, 50-70) = 0 → projected = 70
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 2, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(50); // EV < floor
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(70); // floor = 2nd place = 70
expect(result.projectedPoints).toBe(70); // no negative EV contribution
});
it("partial-score floor for non-scoring round (pos 5) contributes 20 pts", async () => {
// Won Round of 16 (non-scoring): finalPosition=5, isPartialScore=true → floor=20
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(35);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_5_TO_8); // 20
expect(result.projectedPoints).toBe(35); // 20 + (35 - 20)
});
});
describe("pending participant (no result)", () => {
it("contributes 0 to actualPoints and full EV to projectedPoints", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(30);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(0);
expect(result.projectedPoints).toBe(30);
expect(result.participantsFinished).toBe(0);
expect(result.participantsRemaining).toBe(1);
});
it("contributes 0 projected when no EV data exists", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV).mockResolvedValue(null);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(0);
expect(result.projectedPoints).toBe(0);
});
});
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
describe("mixed team (calculateTeamProjectedScore)", () => {
Fix bracket point averaging and partial-score display in standings/breakdown (#159) * Fix bracket point averaging and partial-score display in standings/breakdown Bracket sports (UCL, NBA, NFL, etc.) use averaged points for tied positions (e.g. QF losers all share avg of 5th-8th = 20 pts), but both calculateTeamProjectedScore and getTeamScoreBreakdown were using raw pointsFor5th (25) instead of the bracket-averaged value. This caused the team breakdown page to show incorrect actual points (25 vs 20) and the standings page to show incorrect actualPoints/projectedPoints totals. Additionally, participants with isPartialScore=true (still alive in a bracket with a provisional floor position) were being displayed with a final placement badge (e.g. "5th") instead of "Pending". Changes: - calculateTeamProjectedScore: use calculateBracketPoints for bracket sports; handle isPartialScore participants separately (floor in actualPoints, incremental EV in projectedPoints, Math.max guard for EV < floor edge case) - getTeamScoreBreakdown: same bracket averaging fix; pass isPartialScore through to picks; use explicit finalPosition != null && > 0 check - TeamScoreBreakdown component: show Pending badge for isPartialScore participants; display actual/floor + EV row for all incomplete picks; add "actual / projected" column header subtitle - 9 new unit tests covering all scoring branches including the EV-below- floor clamp edge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix typecheck: cast DUMMY_EV_ROW to any in test fixture ParticipantEV has additional required fields (id, participantId, etc.) that aren't needed for the mock — cast to any to satisfy the type checker. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:00:32 -07:00
it("correctly combines finalized, partial, and pending participants", async () => {
// Pick 1: bracket finalized 1st place → actual=100, no EV
// Pick 2: bracket partial pos 5 (floor=20) → actual=20, EV=35 → +15 projected
// Pick 3: pending → actual=0, EV=25 → +25 projected
// Total: actual=120, projected=120+15+25=160
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
makePick("p3", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV)
.mockResolvedValueOnce(DUMMY_EV_ROW) // p2 — partial
.mockResolvedValueOnce(DUMMY_EV_ROW); // p3 — pending
vi.mocked(calculateEV)
.mockReturnValueOnce(35) // p2 EV
.mockReturnValueOnce(25); // p3 EV
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(120); // 100 + 20
expect(result.projectedPoints).toBe(160); // 120 + 15 + 25
expect(result.participantsFinished).toBe(1);
expect(result.participantsRemaining).toBe(2); // partial + pending
});
});
});
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
describe("calculateTeamScore", () => {
// calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted
// to the teamStandings table. Partial-score participants (still alive in bracket)
// count toward placementCounts using their current position (best available signal),
// but must NOT be counted as completed — they're still in progress.
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
it("partial-score participant contributes floor to totalPoints and placementCounts, but not participantsCompleted", async () => {
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
// QF survivor: finalPosition=5 (floor=20), isPartialScore=true
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 floor in score
expect(result.participantsCompleted).toBe(0); // still alive → not completed
expect(result.placementCounts[5]).toBe(1); // current position counts toward tiebreaker
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
expect(result.participantsTotal).toBe(1);
});
it("finalized bracket participant counts toward placementCounts and participantsCompleted", async () => {
// QF loser: finalPosition=5, isPartialScore=false
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 bracket-averaged
expect(result.participantsCompleted).toBe(1);
expect(result.placementCounts[5]).toBe(1);
});
it("eliminated in non-scoring round (finalPosition=0) counts as completed", async () => {
// Round of 16 loser: 0 pts, fully eliminated (isPartialScore=false)
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 0, isPartialScore: false }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(0); // no points for pre-bracket elimination
expect(result.participantsCompleted).toBe(1); // done — should not count as remaining
expect(result.participantsTotal).toBe(1);
expect(result.placementCounts[1]).toBe(0); // no placement badge
});
it("mixed team: partial counts in totalPoints and placementCounts; finalized also counts as completed", async () => {
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
// p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++
// p2: partial pos 5 (floor 20) → totalPoints +20, placementCounts[5]++, not completed
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(100 + AVG_5_TO_8); // 120
expect(result.participantsCompleted).toBe(1);
expect(result.participantsTotal).toBe(2);
expect(result.placementCounts[1]).toBe(1);
expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
});
it("splits finalized QP tie points across the 8th-place cutoff", async () => {
const db = makeDb(
[
makePick("p1", "ss1", "qualifying_points", { finalPosition: 8, isPartialScore: false }),
],
[
{ sportsSeasonId: "ss1", finalPosition: 8 },
{ sportsSeasonId: "ss1", finalPosition: 8 },
]
);
const result = await calculateTeamScore("team1", "season1", db);
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
expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8
expect(result.participantsCompleted).toBe(1);
expect(result.placementCounts[8]).toBe(1);
});
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160) **Discord webhook improvements** - Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts") - Show username in parentheses in match results for drafted participants (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting the parenthesis for the undrafted side - Username lookup only runs after the webhook URL guard to avoid wasted DB queries - 4 new tests covering all username display combinations **Fix calculateTeamScore partial-score counting bug** - isPartialScore participants were incorrectly counted in participantsCompleted and placementCounts, causing standings to show wrong remaining count and phantom placement badges (e.g. "5th×1") for still-alive bracket participants - Floor points still flow into totalPoints (guaranteed value for ranking) - 3 new unit tests covering partial vs finalized behavior in calculateTeamScore **Admin Force Re-score action** - New "Fantasy Standings" card on every sports season admin page with a Force Re-score button that triggers recalculateStandings on all linked fantasy seasons — useful after data corrections without waiting for a new scoring event - Auth guard added to the action (was missing — layout loader only protects GET) - Returns a meaningful message when no linked seasons exist - Intent discriminant on action returns so success banners use actionData.intent instead of fragile message.includes() checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00
});