From 75798d971126199a7778fce9c1388d489ba8f94a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:38:26 +0000 Subject: [PATCH] Record final-placement points in the score-events ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- app/lib/__tests__/corona-states.test.ts | 122 ++++++++++++ .../__tests__/standings-breakdown.test.ts | 178 ++++++++++++++++++ .../__tests__/team-score-events.test.ts | 177 ++++++++++++++++- app/models/scoring-calculator.ts | 19 +- app/models/team-score-events.ts | 178 +++++++++++++++++- 5 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 app/lib/__tests__/corona-states.test.ts create mode 100644 app/models/__tests__/standings-breakdown.test.ts diff --git a/app/lib/__tests__/corona-states.test.ts b/app/lib/__tests__/corona-states.test.ts new file mode 100644 index 0000000..44d71ed --- /dev/null +++ b/app/lib/__tests__/corona-states.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { computeCoronaStates } from "../corona-states"; + +const RULES = { + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, +}; + +function pick(id: string, scoringPattern: string | null, sportsSeasonId = "ss-1") { + return { participant: { id, sportsSeasonId }, scoringPattern }; +} + +function results( + rows: Array<{ participantId: string; finalPosition: number | null; isPartialScore?: boolean }> +) { + return new Map( + rows.map((r) => [r.participantId, { isPartialScore: false, ...r }]) + ); +} + +describe("computeCoronaStates", () => { + const noTies = new Map>(); + + it("splits points for a tied qualifying_points pick", () => { + // Rahm tied for 8th. 9th is outside the scoring range and contributes 0, + // so (15 + 0) / 2 = 7.5 → 8. Before the shared helper this path awarded the + // full 15 while the standings awarded the split — the reported bug. + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 8 }]), + new Map(), + RULES, + RULES.pointsFor1st, + new Map([["ss-1", new Map([[8, 2]])]]) + ); + + expect(states.rahm).toEqual({ + type: "scored", + points: 8, + brightness: 8 / 100, + }); + }); + + it("awards the full value when the placement is untied", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 8 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toMatchObject({ type: "scored", points: 15 }); + }); + + it("still averages bracket tiers via the bracket template", () => { + const states = computeCoronaStates( + [pick("team-a", "playoff_bracket")], + results([{ participantId: "team-a", finalPosition: 5 }]), + new Map([["ss-1", null]]), + RULES, + RULES.pointsFor1st, + noTies + ); + + // Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20 + expect(states["team-a"]).toMatchObject({ type: "scored", points: 20 }); + }); + + it("scores other patterns straight off the placement", () => { + const states = computeCoronaStates( + [pick("driver", "season_standings")], + results([{ participantId: "driver", finalPosition: 2 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.driver).toMatchObject({ type: "scored", points: 70 }); + }); + + it("marks picks with no result as pending", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toEqual({ type: "pending" }); + }); + + it("marks a finalized zero placement as eliminated", () => { + const states = computeCoronaStates( + [pick("rahm", "qualifying_points")], + results([{ participantId: "rahm", finalPosition: 0 }]), + new Map(), + RULES, + RULES.pointsFor1st, + noTies + ); + + expect(states.rahm).toEqual({ type: "eliminated", points: 0 }); + }); + + it("keeps brightness within range when a tier exceeds maxPoints", () => { + const states = computeCoronaStates( + [pick("champ", "season_standings")], + results([{ participantId: "champ", finalPosition: 1 }]), + new Map(), + RULES, + 10, // deliberately below the 1st-place award + noTies + ); + + expect(states.champ).toMatchObject({ brightness: 1 }); + }); +}); diff --git a/app/models/__tests__/standings-breakdown.test.ts b/app/models/__tests__/standings-breakdown.test.ts new file mode 100644 index 0000000..5b178a3 --- /dev/null +++ b/app/models/__tests__/standings-breakdown.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Regression tests for getTeamScoreBreakdown — the team detail page's points. + * + * This path used to branch only on `isBracket`, so a qualifying_points + * participant tied for a placement was awarded the FULL placement value here + * while calculateTeamScore (which writes team_standings) awarded the split. A + * team therefore read 225 on its team page and 218 in the standings. + * + * The load-bearing assertion is the last one: the breakdown's actualPoints must + * equal calculateTeamScore's totalPoints for the same fixture. Anything that + * makes these two disagree reintroduces the bug. + */ + +vi.mock("~/services/ev-calculator", () => ({ + calculateEV: vi.fn(), +})); + +vi.mock("../participant-expected-value", () => ({ + getParticipantEV: vi.fn(), +})); + +import { getTeamScoreBreakdown } from "../standings"; +import { calculateTeamScore } from "../scoring-calculator"; +import { getParticipantEV } from "../participant-expected-value"; + +const SCORING = { + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, +}; + +function makePick( + participantId: string, + name: string, + scoringPattern: string, + finalPosition: number | null, + { sportsSeasonId = "ss-1", isPartialScore = false, pickNumber = 1 } = {} +) { + return { + pickNumber, + round: 1, + participant: { + id: participantId, + name, + sportsSeasonId, + results: finalPosition === null ? [] : [{ finalPosition, isPartialScore }], + sportsSeason: { scoringPattern, sport: { name: "Golf" } }, + }, + }; +} + +/** + * @param extraResults results for participants NOBODY drafted. They still count + * toward a tie span, which is exactly why the tie query cannot be narrowed to + * drafted participants. + */ +function makeDb( + picks: ReturnType[], + extraResults: { sportsSeasonId: string; finalPosition: number }[] = [] +) { + const seasonResults = [ + ...picks.flatMap((pick) => + pick.participant.results + .filter((r) => r.finalPosition !== null) + .map((r) => ({ + sportsSeasonId: pick.participant.sportsSeasonId, + finalPosition: r.finalPosition, + })) + ), + ...extraResults, + ]; + + return { + query: { + seasons: { findFirst: vi.fn().mockResolvedValue(SCORING) }, + draftPicks: { findMany: vi.fn().mockResolvedValue(picks) }, + scoringEvents: { findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }) }, + seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(seasonResults) }, + teams: { findFirst: vi.fn().mockResolvedValue({ id: "team-1", name: "Mike" }) }, + }, + } as any; +} + +describe("getTeamScoreBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getParticipantEV).mockResolvedValue(null as any); + }); + + it("splits a tied qualifying_points placement", async () => { + // Rahm tied for 8th with an undrafted player: (15 + 0) / 2 = 7.5 → 8. + const db = makeDb( + [makePick("rahm", "Jon Rahm", "qualifying_points", 8)], + [{ sportsSeasonId: "ss-1", finalPosition: 8 }] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0]).toMatchObject({ + participant: expect.objectContaining({ name: "Jon Rahm" }), + finalPosition: 8, + points: 8, + }); + expect(breakdown?.actualPoints).toBe(8); + }); + + it("awards the full placement value when untied", async () => { + const db = makeDb([makePick("rahm", "Jon Rahm", "qualifying_points", 8)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(15); + expect(breakdown?.actualPoints).toBe(15); + }); + + it("counts undrafted participants in the tie span", async () => { + // Three-way tie for 6th where two of the three went undrafted: + // (25 + 15 + 15) / 3 = 18.33 → 18, the rules page's published example. + const db = makeDb( + [makePick("golfer", "Tied Golfer", "qualifying_points", 6)], + [ + { sportsSeasonId: "ss-1", finalPosition: 6 }, + { sportsSeasonId: "ss-1", finalPosition: 6 }, + ] + ); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0].points).toBe(18); + }); + + it("still averages bracket tiers", async () => { + const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + // Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20 + expect(breakdown?.picks[0].points).toBe(20); + }); + + it("leaves picks without a result at zero points", async () => { + const db = makeDb([makePick("pending", "Pending Golfer", "qualifying_points", null)]); + + const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db); + + expect(breakdown?.picks[0]).toMatchObject({ points: 0, isComplete: false }); + expect(breakdown?.actualPoints).toBe(0); + }); + + it("agrees with calculateTeamScore on the same roster", async () => { + // The reported bug in miniature: a tied golfer plus untied picks. Both + // functions read the same fixture, so any divergence is a real regression. + const roster = () => [ + makePick("rahm", "Jon Rahm", "qualifying_points", 8, { pickNumber: 1 }), + makePick("scheffler", "Scottie Scheffler", "qualifying_points", 1, { pickNumber: 2 }), + makePick("team-a", "Team A", "playoff_bracket", 5, { + sportsSeasonId: "ss-2", + pickNumber: 3, + }), + ]; + const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }]; + + const breakdown = await getTeamScoreBreakdown( + "team-1", + "season-1", + makeDb(roster(), undrafted) + ); + const score = await calculateTeamScore( + "team-1", + "season-1", + makeDb(roster(), undrafted) + ); + + expect(breakdown?.actualPoints).toBe(score.totalPoints); + expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8) + }); +}); diff --git a/app/models/__tests__/team-score-events.test.ts b/app/models/__tests__/team-score-events.test.ts index 7f543e6..0f4f438 100644 --- a/app/models/__tests__/team-score-events.test.ts +++ b/app/models/__tests__/team-score-events.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("~/lib/logger", () => ({ - logger: { error: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn() }, })); // ── DB mock helpers ──────────────────────────────────────────────────────── @@ -16,7 +16,13 @@ function makeInsertChain() { interface MakeDbOpts { sportsSeason?: { sport: { name: string } } | null; seasonSports?: { seasonId: string }[]; - picks?: { teamId: string; seasonId: string }[]; + picks?: { teamId: string; seasonId: string; participantId?: string }[]; + scoringEvent?: { id: string; name: string } | null; + participantResults?: { + participantId: string; + finalPosition: number | null; + sportsSeasonId?: string; + }[]; seasons?: { id: string; pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number; @@ -41,6 +47,8 @@ function makeDb(opts: MakeDbOpts = {}) { seasons = [], scoreEventRows = [], participantRows = [], + scoringEvent = null, + participantResults = [], } = opts; const chain = makeInsertChain(); @@ -67,13 +75,24 @@ function makeDb(opts: MakeDbOpts = {}) { seasonParticipants: { findMany: vi.fn().mockResolvedValue(participantRows), }, + scoringEvents: { + findFirst: vi.fn().mockResolvedValue(scoringEvent), + }, + seasonParticipantResults: { + findMany: vi.fn().mockResolvedValue(participantResults), + }, }, } as any, chain, }; } -import { recordTeamScoreEvent, recordMatchScoreEvents, getRecentTeamScoreEvents } from "../team-score-events"; +import { + recordTeamScoreEvent, + recordMatchScoreEvents, + recordFinalPlacementScoreEvents, + getRecentTeamScoreEvents, +} from "../team-score-events"; const BASE_PARAMS = { teamId: "team-1", @@ -358,3 +377,155 @@ describe("getRecentTeamScoreEvents", () => { expect(result[0].participants).toEqual([]); }); }); + +describe("recordFinalPlacementScoreEvents", () => { + beforeEach(() => vi.clearAllMocks()); + + const RULES = { + id: "season-1", + pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, + pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, + }; + + function makeQpDb(overrides: Partial = {}) { + return makeDb({ + sportsSeason: { scoringPattern: "qualifying_points", sport: { name: "Golf" } } as any, + scoringEvent: { id: "event-9", name: "The Open" }, + seasonSports: [{ seasonId: "season-1" }], + seasons: [RULES], + ...overrides, + // Tie counts are grouped by sportsSeasonId, so result rows must carry it. + participantResults: (overrides.participantResults ?? []).map((r) => ({ + sportsSeasonId: "ss-1", + ...r, + })), + }); + } + + it("writes one row per team with the summed award and its participants", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "scheffler", finalPosition: 1 }, + ], + picks: [ + { teamId: "team-1", seasonId: "season-1", participantId: "rahm" }, + { teamId: "team-1", seasonId: "season-1", participantId: "scheffler" }, + ], + }); + + await recordFinalPlacementScoreEvents( + { sportsSeasonId: "ss-1", eventName: "Final Standings" }, + db + ); + + expect(chain.insert).toHaveBeenCalledTimes(1); + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + seasonId: "season-1", + scoringEventId: "event-9", + scoringEventName: "Final Standings", + sportName: "Golf", + matchId: null, + pointsDelta: "115", // 100 (1st) + 15 (8th, untied) + participantIds: ["rahm", "scheffler"], + }) + ); + }); + + it("awards the split value when the placement is tied", async () => { + const { db, chain } = makeQpDb({ + // Rahm ties for 8th with an UNDRAFTED player — the tie still halves it. + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "undrafted-guy", finalPosition: 8 }, + ], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + // (15 + 0) / 2 = 7.5 → 8, matching what the standings show. + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ pointsDelta: "8", participantIds: ["rahm"] }) + ); + }); + + it("keeps each team's award separate", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 8 }, + { participantId: "scheffler", finalPosition: 1 }, + ], + picks: [ + { teamId: "team-1", seasonId: "season-1", participantId: "rahm" }, + { teamId: "team-2", seasonId: "season-1", participantId: "scheffler" }, + ], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).toHaveBeenCalledTimes(2); + const deltasByTeam = Object.fromEntries( + chain.values.mock.calls.map(([v]: any[]) => [v.teamId, v.pointsDelta]) + ); + expect(deltasByTeam).toEqual({ "team-1": "15", "team-2": "100" }); + }); + + it("upserts on the event-level target so re-finalizing does not duplicate", async () => { + const { db, chain } = makeQpDb({ + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0]; + expect(conflictArg.target).toHaveLength(3); + expect(conflictArg.targetWhere).toBeDefined(); + expect(conflictArg.set).toMatchObject({ pointsDelta: "15" }); + }); + + it("skips the write when no scoring event can anchor the row", async () => { + // A null scoringEventId would defeat the partial unique index, since + // Postgres treats NULLs as distinct — better to skip than duplicate. + const { db, chain } = makeQpDb({ + scoringEvent: null, + participantResults: [{ participantId: "rahm", finalPosition: 8 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).not.toHaveBeenCalled(); + }); + + it("ignores non-scoring placements", async () => { + const { db, chain } = makeQpDb({ + participantResults: [ + { participantId: "rahm", finalPosition: 0 }, + { participantId: "other", finalPosition: null }, + ], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.insert).not.toHaveBeenCalled(); + }); + + it("scores season_standings placements with the shared tier averaging", async () => { + const { db, chain } = makeQpDb({ + sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any, + participantResults: [{ participantId: "driver", finalPosition: 3 }], + picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }], + }); + + await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ sportName: "F1", pointsDelta: "50" }) + ); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 7b3ef84..a350e8f 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -12,7 +12,10 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff- import { getUserDisplayName } from "~/models/user"; import { findDiscordIdsByUserIds } from "~/models/account"; import { createDailySnapshot } from "~/models/standings"; -import { recordMatchScoreEvents } from "~/models/team-score-events"; +import { + recordMatchScoreEvents, + recordFinalPlacementScoreEvents, +} from "~/models/team-score-events"; import { logger } from "~/lib/logger"; import { getEventResults } from "./event-result"; import { @@ -1170,6 +1173,13 @@ export async function finalizeQualifyingPoints( }) .where(eq(schema.sportsSeasons.id, sportsSeasonId)); + // Ledger the placement points so this season shows up in Recent Scores — + // QP seasons award everything here, with no per-match deltas to record. + await recordFinalPlacementScoreEvents( + { sportsSeasonId, eventName: "Final Standings" }, + db + ); + // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" }); @@ -1273,6 +1283,13 @@ export async function processSeasonStandings( } } + // Ledger the placement points — season_standings has the same one-shot award + // shape as qualifying_points and was likewise absent from Recent Scores. + await recordFinalPlacementScoreEvents( + { sportsSeasonId, eventName: "Season Complete" }, + db + ); + // Trigger recalculation for all affected leagues await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" }); diff --git a/app/models/team-score-events.ts b/app/models/team-score-events.ts index 077772c..6bdcb6d 100644 --- a/app/models/team-score-events.ts +++ b/app/models/team-score-events.ts @@ -1,7 +1,15 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, inArray, desc, sql, and } from "drizzle-orm"; -import { calculateBracketPoints, type ScoringRules } from "~/models/scoring-rules"; +import { + calculateBracketPoints, + calculatePickPoints, + type ScoringRules, +} from "~/models/scoring-rules"; +import { + getSharedPlacementCounts, + lookupSharedPlacementCount, +} from "~/models/participant-result"; import { findParticipantNamesByIds } from "~/models/season-participant"; import { logger } from "~/lib/logger"; @@ -190,6 +198,174 @@ export async function recordMatchScoreEvents( } } +/** + * Records ledger rows for a sports season whose final placements have just been + * assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1). + * + * These patterns award all of their points in one step at finalization, so they + * have no per-match deltas and were never written to team_score_events at all: + * recordMatchScoreEvents is bracket-only and fires from match processing. The + * effect was that golf results silently never appeared in Recent Scores. + * + * One row per team (matching the event-level unique index), carrying that team's + * summed award and every participant that contributed. Points come from + * calculatePickPoints, so a tied golfer contributes the same split award the + * standings show. + * + * `eventId` anchors the row and MUST resolve to a real scoring event: the + * event-level unique index is (teamId, seasonId, scoringEventId) and Postgres + * treats NULLs as distinct, so a null anchor would silently duplicate rows on + * every re-finalization instead of upserting. If no anchor can be found the + * ledger write is skipped rather than risking duplicates — standings are + * unaffected either way. + */ +export async function recordFinalPlacementScoreEvents( + params: { + sportsSeasonId: string; + eventId?: string | null; + eventName?: string | null; + }, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const sportsSeason = await db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, params.sportsSeasonId), + columns: { id: true, scoringPattern: true }, + with: { sport: { columns: { name: true } } }, + }); + if (!sportsSeason) return; + + // Resolve the anchor event: the caller's, else the most recently completed + // event for this sports season. + let eventId = params.eventId ?? null; + let eventName = params.eventName ?? null; + if (!eventId) { + const anchor = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), + columns: { id: true, name: true }, + orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.createdAt)], + }); + if (!anchor) { + logger.warn( + `[TeamScoreEvents] No scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` + ); + return; + } + eventId = anchor.id; + eventName = eventName ?? anchor.name; + } + if (!eventId) return; + + // Scoring placements for this sports season, plus the tie spans they imply. + const results = await db.query.seasonParticipantResults.findMany({ + where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId), + columns: { participantId: true, finalPosition: true }, + }); + const positionByParticipantId = new Map(); + for (const row of results) { + if (row.finalPosition !== null && row.finalPosition > 0) { + positionByParticipantId.set(row.participantId, row.finalPosition); + } + } + if (positionByParticipantId.size === 0) return; + + const sharedPlacementCounts = await getSharedPlacementCounts([params.sportsSeasonId], db); + + const seasonSports = await db.query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), + columns: { seasonId: true }, + }); + if (seasonSports.length === 0) return; + const seasonIds = seasonSports.map((ss) => ss.seasonId); + + const picks = await db.query.draftPicks.findMany({ + where: and( + inArray(schema.draftPicks.seasonId, seasonIds), + inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()]) + ), + columns: { teamId: true, seasonId: true, participantId: true }, + }); + if (picks.length === 0) return; + + const seasonRows = await db.query.seasons.findMany({ + where: inArray(schema.seasons.id, seasonIds), + columns: { + id: true, + pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true, + pointsFor4th: true, pointsFor5th: true, pointsFor6th: true, + pointsFor7th: true, pointsFor8th: true, + }, + }); + const rulesBySeasonId = new Map( + seasonRows.map((s) => [s.id, { + pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd, + pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th, + pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th, + pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th, + }]) + ); + + // Accumulate per (season, team) — the ledger's event-level grain. + const byTeam = new Map< + string, + { teamId: string; seasonId: string; points: number; participantIds: string[] } + >(); + + for (const pick of picks) { + const rules = rulesBySeasonId.get(pick.seasonId); + const finalPosition = positionByParticipantId.get(pick.participantId); + if (!rules || finalPosition === undefined) continue; + + const points = calculatePickPoints( + finalPosition, + sportsSeason.scoringPattern, + rules, + { + tiedParticipants: lookupSharedPlacementCount( + sharedPlacementCounts, + params.sportsSeasonId, + finalPosition + ), + } + ); + if (points <= 0) continue; + + const key = `${pick.seasonId}:${pick.teamId}`; + const entry = byTeam.get(key) ?? { + teamId: pick.teamId, + seasonId: pick.seasonId, + points: 0, + participantIds: [], + }; + entry.points += points; + entry.participantIds.push(pick.participantId); + byTeam.set(key, entry); + } + + for (const entry of byTeam.values()) { + try { + await recordTeamScoreEvent( + { + teamId: entry.teamId, + seasonId: entry.seasonId, + scoringEventId: eventId, + scoringEventName: eventName, + sportName: sportsSeason.sport?.name ?? null, + participantIds: entry.participantIds, + pointsDelta: entry.points, + }, + db + ); + } catch (err) { + logger.error( + `[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`, + err + ); + } + } +} + export interface TeamScoreEventEntry { id: string; teamId: string;