diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts index 5148b04..0354fee 100644 --- a/app/models/__tests__/team-projected-score.test.ts +++ b/app/models/__tests__/team-projected-score.test.ts @@ -240,9 +240,10 @@ describe("calculateTeamProjectedScore", () => { describe("calculateTeamScore", () => { // calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted // to the teamStandings table. Partial-score participants (still alive in bracket) - // must NOT be counted as completed or appear in placement tiebreakers. + // count toward placementCounts using their current position (best available signal), + // but must NOT be counted as completed — they're still in progress. - it("partial-score participant contributes floor to totalPoints but not to placementCounts or participantsCompleted", async () => { + it("partial-score participant contributes floor to totalPoints and placementCounts, but not participantsCompleted", async () => { // QF survivor: finalPosition=5 (floor=20), isPartialScore=true const db = makeDb([ makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), @@ -252,7 +253,7 @@ describe("calculateTeamScore", () => { 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(0); // no placement badge until finalized + expect(result.placementCounts[5]).toBe(1); // current position counts toward tiebreaker expect(result.participantsTotal).toBe(1); }); @@ -283,9 +284,9 @@ describe("calculateTeamScore", () => { expect(result.placementCounts[1]).toBe(0); // no placement badge }); - it("mixed team: partial counts in totalPoints only; finalized counts everywhere", async () => { + it("mixed team: partial counts in totalPoints and placementCounts; finalized also counts as completed", async () => { // p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++ - // p2: partial pos 5 (floor 20) → totalPoints +20, no placement, not completed + // p2: partial pos 5 (floor 20) → totalPoints +20, placementCounts[5]++, not completed const db = makeDb([ makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }), makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), @@ -297,6 +298,6 @@ describe("calculateTeamScore", () => { expect(result.participantsCompleted).toBe(1); expect(result.participantsTotal).toBe(2); expect(result.placementCounts[1]).toBe(1); - expect(result.placementCounts[5]).toBe(0); + expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker }); }); diff --git a/app/models/__tests__/tiebreaker-logic.test.ts b/app/models/__tests__/tiebreaker-logic.test.ts index a88e487..33a346f 100644 --- a/app/models/__tests__/tiebreaker-logic.test.ts +++ b/app/models/__tests__/tiebreaker-logic.test.ts @@ -292,6 +292,41 @@ describe("Tiebreaker Logic", () => { }); }); + describe("Pending Participants (issue #244)", () => { + // calculateTeamScore now includes partial-score participants' current positions + // in placementCounts. These tests verify the tiebreaker works correctly when + // pending positions are already reflected in the counts passed in. + + it("should rank teams tied on points equally when their current positions are also tied", () => { + // All six teams currently sit at 1st — their pending events haven't separated them yet. + // placementCounts reflect the current (partial) position, so all have { 1: 1 }. + const teams = [ + { teamId: "allen", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "ender", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "zed", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("allen")).toBe(1); + expect(ranks.get("ender")).toBe(1); + expect(ranks.get("zed")).toBe(1); + }); + + it("should break the tie using current position when one team is ahead in standings", () => { + // Two teams tied on points; one's pending participant is currently 1st, the other 2nd. + const teams = [ + { teamId: "team-a", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "team-b", totalPoints: 20, placementCounts: { 2: 1 } as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + }); + describe("Complex Tiebreaker Scenarios", () => { it("should handle 8-way cascade ending in complete tie", () => { // Two teams identical through all 8 placements diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index b48ddc4..450c616 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -900,12 +900,16 @@ export async function calculateTeamScore( } totalPoints += points; + // All participants with a valid position count toward the placement tiebreaker, + // including those still in progress (isPartialScore). Their current position + // is the best available signal, and if it changes recalculateStandings will run again. + if (result.finalPosition <= 8) { + placementCounts[result.finalPosition]++; + } + if (!result.isPartialScore) { - // Only fully finalized participants count toward completed and placement tiebreakers + // Only fully finalized participants count toward "completed" progress tracking participantsCompleted++; - if (result.finalPosition >= 1 && result.finalPosition <= 8) { - placementCounts[result.finalPosition]++; - } } } else if (result && !result.isPartialScore) { // Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done