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);
expect(result.totalPoints).toBe(7.5); // (15 + 0) / 2
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
});