From beab6400b24ea6fa08db6bded69330411704410b Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:34:10 -0700 Subject: [PATCH] Fix partial bracket scoring, rank labels, and Discord notification bugs (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet Sixteen winners entering the first scoring round get a floor - AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8])) instead of one shared avg([5-8]) pool - Eliminated teams now always show their correct final rank (e.g. T33 for NCAAM R64 losers) even during partial round scoring, by deriving the rank label from the total match count per round rather than the dynamic still-alive count - Discord notifications now fire when a drafted participant is eliminated with 0 points (e.g. R64 losers whose team score doesn't change) - Discord notifications no longer include all prior matches — scoped to the current batch via matchIds - Discord section headers have spacing; rank medals replaced with numbers - Rounds auto-complete when all matches are marked done; manual Complete Round dropdown removed - Fixed double Discord notification when the last match in a round triggers auto-complete: processPlayoffEvent now accepts skipRecalculate so the caller controls when the notification fires - Fixed falsy check on finalPosition=0 in calculateTeamScore - Undrafted 0-point participants filtered from Eliminated Teams display Co-authored-by: Claude Sonnet 4.6 --- app/components/scoring/PlayoffBracket.tsx | 37 +++--- .../__tests__/process-match-result.test.ts | 43 +++++- .../progressive-floor-scoring.test.ts | 44 ++++++- app/models/scoring-calculator.ts | 124 ++++++++++++++---- app/models/scoring-rules.ts | 31 ++++- ...sons.$id.events.$eventId.bracket.server.ts | 42 +++++- ...ts-seasons.$id.events.$eventId.bracket.tsx | 36 ----- ...d.sports-seasons.$sportsSeasonId.server.ts | 6 +- app/services/__tests__/discord.test.ts | 8 +- app/services/discord.ts | 9 +- 10 files changed, 280 insertions(+), 100 deletions(-) diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 7dbfad4..2650767 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -218,29 +218,28 @@ export function PlayoffBracket({ }); } - // Walk rounds latest→earliest to assign rank labels (no mutation) - // nextRank must start after the still-alive participants (not always 2) + // Walk rounds latest→earliest to assign rank labels. + // Use TOTAL match count per round (not eliminated-so-far) so that partial scoring + // shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33 + // even while other R64 games are still pending. const allBracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); } - const totalEliminatedInBracket = [...losersByRound.values()].reduce( - (sum, losers) => sum + losers.length, - 0 - ); - const stillAlive = - allBracketParticipantIds.size - totalEliminatedInBracket - (bracketWinner ? 1 : 0); const rankedEntries: EliminatedEntry[] = []; - let nextRank = stillAlive + (bracketWinner ? 2 : 1); + let nextRank = 2; // rank 1 = champion (even if not yet decided) for (let ri = rounds.length - 1; ri >= 0; ri--) { - const roundLosers = losersByRound.get(rounds[ri]) || []; - if (roundLosers.length === 0) continue; - const rankLabel = `T${nextRank}`; - for (const loser of roundLosers) { - rankedEntries.push({ ...loser, rankLabel }); + const roundName = rounds[ri]; + const roundLosers = losersByRound.get(roundName) || []; + const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0; + if (roundLosers.length > 0) { + const rankLabel = `T${nextRank}`; + for (const loser of roundLosers) { + rankedEntries.push({ ...loser, rankLabel }); + } } - nextRank += roundLosers.length; + nextRank += totalMatchesInRound; } // Exclude pre-eliminated participants already ranked via bracket match losers @@ -263,6 +262,10 @@ export function PlayoffBracket({ .map((id) => participantMap.get(id)) .filter((p): p is Participant => p !== undefined); + // Hide participants with 0 points that nobody drafted — they add noise without value. + const isDraftedOrScoring = (participantId: string) => + ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; + // Hoist winner row lookups so we don't need an IIFE in JSX const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined; @@ -571,7 +574,7 @@ export function PlayoffBracket({ )} - {rankedEntries.map((entry, i) => { + {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry, i) => { const isOwned = userParticipantSet.has(entry.participant.id); const pts = pointsMap.get(entry.participant.id); return ( @@ -609,7 +612,7 @@ export function PlayoffBracket({ ); })} - {filteredPreEliminated.map((p) => { + {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { const isOwned = userParticipantSet.has(p.id); const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; const pts = pointsMap.get(p.id); diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index 42fcfac..1b9ffb5 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -147,7 +147,7 @@ describe("processMatchResult", () => { }); describe("Non-scoring round (isScoring=false)", () => { - it("loser eliminated (pos 0 final), winner gets provisional T5 floor", async () => { + it("loser eliminated (pos 0 final), winner gets provisional T5 floor (no template = legacy)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round: "Round of 16", isScoring: false }, db); @@ -155,6 +155,47 @@ describe("processMatchResult", () => { expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); }); + + describe("NCAA 68 — only Sweet Sixteen winners earn a floor", () => { + const NCAA = { bracketTemplateId: "ncaa_68" }; + + it("Round of 64: loser=0, winner gets NO score (not yet in scoring bracket)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult({ ...BASE, ...NCAA, round: "Round of 64", isScoring: false }, db); + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + }); + + it("Round of 32: loser=0, winner gets NO score (not yet in scoring bracket)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult({ ...BASE, ...NCAA, round: "Round of 32", isScoring: false }, db); + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + }); + + it("Sweet Sixteen: loser=0, winner gets provisional T5 floor (entering Elite Eight)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult({ ...BASE, ...NCAA, round: "Sweet Sixteen", isScoring: false }, db); + expect(insertedRows).toHaveLength(2); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); + }); + + it("First Four: loser=0, winner gets NO score (feeds into Round of 64, not scoring)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult({ ...BASE, ...NCAA, round: "First Four", isScoring: false }, db); + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + }); + }); + + it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => { + const { db, insertedRows } = makeDb(); + await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db); + expect(insertedRows).toHaveLength(2); + expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); + expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); + }); }); describe("Unrecognized scoring round", () => { diff --git a/app/models/__tests__/progressive-floor-scoring.test.ts b/app/models/__tests__/progressive-floor-scoring.test.ts index d62068f..849d0ee 100644 --- a/app/models/__tests__/progressive-floor-scoring.test.ts +++ b/app/models/__tests__/progressive-floor-scoring.test.ts @@ -118,10 +118,10 @@ describe("Progressive Floor Scoring", () => { }); describe("Non-scoring round provisional floors", () => { - it("R16 winners (non-scoring) get T5 floor = 20 pts", () => { + it("R16 winners (non-scoring, standard bracket) get T5 floor = 20 pts", () => { // After winning a non-scoring R16, participants are guaranteed top-8. // processPlayoffEvent assigns finalPosition=5 with isPartialScore=true. - // calculateBracketPoints maps position 5 → avg(5-8) = 20 pts. + // calculateBracketPoints maps position 5 → avg(5-8) = 20 pts (standard tier). expect(calculateBracketPoints(5, DEFAULT_SCORING)).toBe(20); }); @@ -140,6 +140,46 @@ describe("Progressive Floor Scoring", () => { }); }); + describe("AFL bracket tier scoring (afl_10)", () => { + it("Elimination Finals losers (pos 7) get T7-T8 avg = 15 pts, not avg([5-8])", () => { + // EF losers share T7-T8 with one other team; they should NOT share with T5-T6 teams. + expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15); // avg([7,8]) = 15 + }); + + it("Semi-Finals losers (pos 5) get T5-T6 avg = 25 pts, not avg([5-8])", () => { + // SF losers share T5-T6 with one other team; they should NOT be averaged with T7-T8. + expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25); // avg([5,6]) = 25 + }); + + it("T5-T6 and T7-T8 are different in AFL (25 vs 15)", () => { + const sfLoserPoints = calculateBracketPoints(5, DEFAULT_SCORING, "afl_10"); + const efLoserPoints = calculateBracketPoints(7, DEFAULT_SCORING, "afl_10"); + expect(sfLoserPoints).toBeGreaterThan(efLoserPoints); + }); + + it("standard bracket: position 5 still gets avg([5-8]) = 20 pts without afl_10 template", () => { + // Standard brackets have all 4 QF losers at position 5, sharing avg([5,6,7,8]) + expect(calculateBracketPoints(5, DEFAULT_SCORING)).toBe(20); + expect(calculateBracketPoints(5, DEFAULT_SCORING, null)).toBe(20); + expect(calculateBracketPoints(5, DEFAULT_SCORING, "ncaa_68")).toBe(20); + }); + + it("AFL pos 8 also gets T7-T8 avg = 15 pts", () => { + expect(calculateBracketPoints(8, DEFAULT_SCORING, "afl_10")).toBe(15); + }); + + it("AFL pos 6 also gets T5-T6 avg = 25 pts", () => { + expect(calculateBracketPoints(6, DEFAULT_SCORING, "afl_10")).toBe(25); + }); + + it("AFL 3rd-4th and 1st-2nd are same as standard", () => { + expect(calculateBracketPoints(3, DEFAULT_SCORING, "afl_10")).toBe(45); // avg([3,4]) + expect(calculateBracketPoints(4, DEFAULT_SCORING, "afl_10")).toBe(45); + expect(calculateBracketPoints(2, DEFAULT_SCORING, "afl_10")).toBe(70); + expect(calculateBracketPoints(1, DEFAULT_SCORING, "afl_10")).toBe(100); + }); + }); + describe("Progressive scoring progression", () => { it("score increases as participant advances through bracket", () => { // A participant's guaranteed minimum only increases, never decreases. diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index f2b6dfc..704f095 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -5,6 +5,7 @@ import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calcu import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; +import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; /** * Core scoring calculation engine @@ -74,6 +75,31 @@ const TEMPLATE_ROUND_CONFIG: Record> }, }; +/** + * Returns true if a non-scoring round's winners are entering the first scoring round + * (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor). + * + * For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → + * Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring + * bracket — Round of 64 and Round of 32 winners should not receive any floor yet. + * + * Falls back to true when template/round info is unavailable to preserve legacy behavior. + */ +function doesNonScoringRoundFeedIntoScoringRound( + round: string, + bracketTemplateId: string | null | undefined +): boolean { + if (!bracketTemplateId) return true; // Legacy: preserve old behavior + const template = BRACKET_TEMPLATES[bracketTemplateId]; + if (!template) return true; // Unknown template: preserve old behavior + const currentRound = template.rounds.find((r) => r.name === round); + if (!currentRound) return true; // Unknown round: preserve old behavior + const nextRoundName = currentRound.feedsInto; + if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring) + const nextRound = template.rounds.find((r) => r.name === nextRoundName); + return nextRound?.isScoring === true; +} + /** * Look up the scoring config for a given round name, applying any template-specific * overrides before falling back to the standard ROUND_CONFIG. @@ -103,7 +129,8 @@ function getRoundConfig( */ export async function processPlayoffEvent( eventId: string, - providedDb?: ReturnType + providedDb?: ReturnType, + options?: { skipRecalculate?: boolean } ): Promise { const db = providedDb || database(); @@ -202,14 +229,16 @@ export async function processPlayoffEvent( if (!isScoring) { // Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). - // Assumption: once eliminated in a non-scoring round a participant cannot - // re-enter the bracket. Winners bank a provisional T5–T8 floor immediately - // (guaranteed top-8 finish at worst for standard 8-team scoring brackets). + // Winners only bank a provisional T5–T8 floor if they're entering the first + // scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences + // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should + // receive floor points — R64 and R32 winners are not yet guaranteed top-8. + const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId); for (const match of matches) { if (match.loserId) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } - if (match.winnerId) { + if (match.winnerId && awardFloor) { await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); } } @@ -273,8 +302,10 @@ export async function processPlayoffEvent( } } - // Recalculate standings for all affected leagues - await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); + // Recalculate standings for all affected leagues (skipped when caller handles it) + if (!options?.skipRecalculate) { + await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId }); + } // Auto-trigger probability recalculation after result try { @@ -312,19 +343,25 @@ export async function processMatchResult( bracketTemplateId?: string | null; eventId?: string; eventName?: string; + /** When set, Discord notification only shows this match (not all completed matches for the event). */ + matchId?: string; skipSideEffects?: boolean; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); - const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, skipSideEffects } = params; + const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects } = params; if (!isScoring) { - // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts), - // winner banks provisional T5–T8 floor immediately. - // Assumption: non-scoring round losers cannot re-enter the bracket. + // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts). + // Winner only banks provisional T5–T8 floor if they're entering the first + // scoring round (guaranteed top-8). For multi-round pre-bracket sequences + // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners + // should receive floor points. await upsertParticipantResult(loserId, sportsSeasonId, 0, db); - await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { + await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + } } else { const config = getRoundConfig(round, bracketTemplateId); @@ -347,7 +384,10 @@ export async function processMatchResult( } if (!skipSideEffects) { - await recalculateAffectedLeagues(sportsSeasonId, db, (eventName || eventId) ? { eventName, eventId } : undefined); + const sideEffectOptions = (eventName || eventId) + ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined } + : undefined; + await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); try { await updateProbabilitiesAfterResult(sportsSeasonId, true); } catch (error) { @@ -811,16 +851,36 @@ export async function calculateTeamScore( }; let participantsCompleted = 0; + // Cache bracket template IDs per sports season (one extra query per unique bracket season). + // Needed to determine the correct scoring tier structure (e.g. AFL splits 5–8 into two pairs). + const bracketTemplateCache = new Map(); + async function getBracketTemplate(sportsSeasonId: string): Promise { + if (bracketTemplateCache.has(sportsSeasonId)) { + return bracketTemplateCache.get(sportsSeasonId)!; + } + const event = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + columns: { bracketTemplateId: true }, + }); + const templateId = event?.bracketTemplateId ?? null; + bracketTemplateCache.set(sportsSeasonId, templateId); + return templateId; + } + for (const pick of picks) { // One result per participant per sports season (enforced by upsertParticipantResult). // If duplicates exist due to data corruption, the first row wins — acceptable trade-off. const result = pick.participant.results[0]; - if (result && result.finalPosition) { + if (result && result.finalPosition != null && result.finalPosition > 0) { const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket"; - const points = isBracket - ? calculateBracketPoints(result.finalPosition, scoringRules) - : calculateFantasyPoints(result.finalPosition, scoringRules); + let points: number; + if (isBracket) { + const templateId = await getBracketTemplate(pick.participant.sportsSeasonId); + points = calculateBracketPoints(result.finalPosition, scoringRules, templateId); + } else { + points = calculateFantasyPoints(result.finalPosition, scoringRules); + } totalPoints += points; participantsCompleted++; @@ -1109,7 +1169,7 @@ export async function recalculateStandings( export async function recalculateAffectedLeagues( sportsSeasonId: string, providedDb?: ReturnType, - options?: { eventName?: string; eventId?: string } + options?: { eventName?: string; eventId?: string; matchIds?: string[] } ): Promise { const db = providedDb || database(); @@ -1127,7 +1187,9 @@ export async function recalculateAffectedLeagues( }); const sportName = sportsSeason?.sport?.name; - // Look up completed matches for the event (with participant names) if an eventId was provided + // Look up matches for the Discord notification. + // When matchIds is provided, only those specific matches are shown (e.g. current batch). + // Falling back to all completed matches for the event would include prior rounds. let allCompletedMatches: Array<{ winnerId: string | null; loserId: string | null; @@ -1136,11 +1198,14 @@ export async function recalculateAffectedLeagues( }> = []; if (options?.eventId) { + const whereClause = options.matchIds?.length + ? inArray(schema.playoffMatches.id, options.matchIds) + : and( + eq(schema.playoffMatches.scoringEventId, options.eventId), + eq(schema.playoffMatches.isComplete, true) + ); const matches = await db.query.playoffMatches.findMany({ - where: and( - eq(schema.playoffMatches.scoringEventId, options.eventId), - eq(schema.playoffMatches.isComplete, true) - ), + where: whereClause, with: { winner: true, loser: true, @@ -1181,8 +1246,6 @@ export async function recalculateAffectedLeagues( return prev === undefined || prev !== parseFloat(s.totalPoints); }); - if (!hasChanges) continue; - // Look up the league's Discord webhook URL via the season const season = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), @@ -1192,8 +1255,12 @@ export async function recalculateAffectedLeagues( const webhookUrl = season?.league?.discordWebhookUrl; if (!webhookUrl) continue; - // Filter matches to only those involving participants drafted in this season + // Filter matches to only those involving participants drafted in this season. + // We compute this BEFORE the hasChanges gate because 0-point eliminations + // (e.g. R64 losers whose finalPosition=0 doesn't affect team scores) still + // warrant a Discord notification when a drafted team is eliminated. let scoredMatches: ScoredMatch[] | undefined; + let hasDraftedParticipantMatches = false; if (allCompletedMatches.length > 0) { const draftPicks = await db.query.draftPicks.findMany({ where: eq(schema.draftPicks.seasonId, seasonId), @@ -1206,6 +1273,8 @@ export async function recalculateAffectedLeagues( (m.loserId && draftedIds.has(m.loserId)) ); + hasDraftedParticipantMatches = relevant.length > 0; + if (relevant.length > 0) { scoredMatches = relevant .filter((m) => m.winnerName && m.loserName) @@ -1216,6 +1285,9 @@ export async function recalculateAffectedLeagues( } } + // Skip notification if scores didn't change AND no drafted participants were scored. + if (!hasChanges && !hasDraftedParticipantMatches) continue; + const standings = afterStandings.map((s) => ({ teamId: s.teamId, teamName: s.team.name, diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index dc590ab..cf8146c 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -104,6 +104,18 @@ export function calculateAveragedPoints( return total / placements.length; } +/** + * Tier definitions for brackets where positions 5–8 split into two separate pairs. + * + * Most brackets (standard single-elimination) have ONE tier covering positions 5–8: + * four QF losers all tie and share the combined prize pool → avg([5,6,7,8]). + * + * AFL is different: it has TWO distinct tiers in the 5–8 zone: + * - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6]) + * - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8]) + */ +const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]); + /** * Calculate fantasy points for a bracket placement, averaging tied positions. * @@ -113,22 +125,31 @@ export function calculateAveragedPoints( * 3rd-4th: two SF losers share these positions → averaged * 5th-8th: four QF losers share these positions → averaged * - * Use this instead of calculateFantasyPoints for playoff_bracket scoring. + * AFL bracket tiers (afl_10): + * 5th-6th: Semi-Finals losers → averaged separately from 7th-8th + * 7th-8th: Elimination Finals losers → averaged separately from 5th-6th * - * TODO: This hardcodes 8-team bracket tiers. Future support for larger brackets - * (e.g. 16-team with 9th-16th scoring) will require parameterised tier config. + * Use this instead of calculateFantasyPoints for playoff_bracket scoring. */ export function calculateBracketPoints( finalPosition: number, - rules: ScoringRules + rules: ScoringRules, + bracketTemplateId?: string | null ): number { if (finalPosition <= 0) return 0; if (finalPosition === 1) return rules.pointsFor1st; if (finalPosition === 2) return rules.pointsFor2nd; if (finalPosition === 3 || finalPosition === 4) return calculateAveragedPoints([3, 4], rules); - if (finalPosition >= 5 && finalPosition <= 8) + if (finalPosition >= 5 && finalPosition <= 8) { + if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) { + // AFL-style: two separate 2-team tiers within 5–8 + if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules); + return calculateAveragedPoints([7, 8], rules); + } + // Standard: all four QF losers share one tier return calculateAveragedPoints([5, 6, 7, 8], rules); + } return 0; } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 5533e76..d4c3dfe 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -190,6 +190,37 @@ export async function action({ request, params }: Route.ActionArgs) { } } + /** + * Auto-complete a round when every match in it is marked complete. + * Updates scoringEvents.playoffRound and calls processPlayoffEvent so that + * non-bracket eliminations are recorded and probabilities are refreshed. + * This replaces the manual "Complete Round" button. + * + * skipRecalculate=true because the caller already sent a Discord notification + * scoped to the current batch of matches — we don't want a second one that + * would show all previously-completed matches in the event. + */ + async function autoCompleteRoundIfDone( + eventId: string, + round: string, + sportsSeasonId: string, + db: ReturnType + ): Promise { + const allMatches = await findPlayoffMatchesByEventId(eventId); + const roundMatches = allMatches.filter((m) => m.round === round); + if (roundMatches.length === 0) return; + const allDone = roundMatches.every((m) => m.isComplete); + if (!allDone) return; + + await db + .update(schema.scoringEvents) + .set({ playoffRound: round, updatedAt: new Date() }) + .where(eq(schema.scoringEvents.id, eventId)); + + await processPlayoffEvent(eventId, db, { skipRecalculate: true }); + console.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`); + } + if (intent === "set-winner") { const matchId = formData.get("matchId"); const winnerId = formData.get("winnerId"); @@ -259,8 +290,12 @@ export async function action({ request, params }: Route.ActionArgs) { bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, + matchId, }); + const db = database(); + await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); + return { success: "Winner set successfully" }; } catch (error) { console.error("Error setting winner:", error); @@ -304,6 +339,7 @@ export async function action({ request, params }: Route.ActionArgs) { // Process each winner assignment let successCount = 0; const errors: string[] = []; + const processedMatchIds: string[] = []; for (const { matchId, winnerId } of winnerAssignments) { try { @@ -365,6 +401,7 @@ export async function action({ request, params }: Route.ActionArgs) { }); successCount++; + processedMatchIds.push(matchId); } catch (error) { console.error(`Error setting winner for match ${matchId}:`, error); errors.push( @@ -374,14 +411,17 @@ export async function action({ request, params }: Route.ActionArgs) { } // Run side effects once for the whole batch (avoids N redundant recalcs). + // Pass matchIds so Discord only shows the matches from this submission, not all + // previously completed matches in the event. if (successCount > 0) { const db = database(); - await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined }); + await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); } catch (error) { console.error(`Error updating probabilities after batch round winners:`, error); } + await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } if (errors.length > 0 && successCount === 0) { diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 6eac747..c204e67 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -1097,42 +1097,6 @@ export default function EventBracket({ ))} - {/* Complete Round Button */} - {availableRounds.length > 0 && !event.isComplete && ( - - - Complete Round - - Finalize the current round and calculate placements - - - -
- -
-
- - -
- -
-
-
-
- )} {/* Finalize Bracket Button */} {allMatchesComplete && !event.isComplete && ( diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 7012a0b..d647efa 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -147,6 +147,8 @@ export async function loader(args: Route.LoaderArgs) { where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), }); + let templateId: string | undefined; + if (events.length > 0) { const eventIds = events.map((e) => e.id); const matches = await db.query.playoffMatches.findMany({ @@ -161,7 +163,7 @@ export async function loader(args: Route.LoaderArgs) { }); playoffMatches = matches; - const templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId; + templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined; const template = templateId ? getBracketTemplate(templateId) : undefined; playoffRounds = getOrderedRoundsFromMatches(matches, template); } @@ -206,7 +208,7 @@ export async function loader(args: Route.LoaderArgs) { }) .map((r) => ({ participantId: r.participantId, - points: calculateBracketPoints(r.finalPosition!, scoringRules), + points: calculateBracketPoints(r.finalPosition!, scoringRules, templateId), })); // Track which participants have provisional (floor) scores — still competing diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index d3c8846..7e2719d 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -143,7 +143,7 @@ describe("sendStandingsUpdateNotification", () => { expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings")); }); - it("uses medal emoji for top 3 ranks in description", async () => { + it("uses plain numbers for all ranks in description", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", @@ -157,9 +157,9 @@ describe("sendStandingsUpdateNotification", () => { }); const desc = getDescription(); - expect(desc).toContain("🥇 Alpha"); - expect(desc).toContain("🥈 Beta"); - expect(desc).toContain("🥉 Gamma"); + expect(desc).toContain("1. Alpha"); + expect(desc).toContain("2. Beta"); + expect(desc).toContain("3. Gamma"); expect(desc).toContain("4. Delta"); }); diff --git a/app/services/discord.ts b/app/services/discord.ts index 94b12bc..63fe1d3 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -55,8 +55,6 @@ export async function sendStandingsUpdateNotification({ eventName?: string; scoredMatches?: ScoredMatch[]; }): Promise { - const RANK_MEDALS = ["🥇", "🥈", "🥉"]; - const sections: string[] = []; // Header: "Sport Name — Event Name" @@ -67,14 +65,14 @@ export async function sendStandingsUpdateNotification({ // Scored matches section if (scoredMatches && scoredMatches.length > 0) { - sections.push("**Scored Matches**"); + sections.push("\n**Scored Matches**"); for (const match of scoredMatches) { sections.push(`• **${match.winnerName}** def. ${match.loserName}`); } } // Standings section - sections.push("**Current Standings**"); + sections.push("\n**Current Standings**"); for (const s of standings) { const prev = previousStandings.get(s.teamId); let delta = ""; @@ -83,8 +81,7 @@ export async function sendStandingsUpdateNotification({ const sign = diff > 0 ? "+" : ""; delta = ` **(${sign}${diff} pts)**`; } - const medal = RANK_MEDALS[s.rank - 1] ?? `${s.rank}.`; - sections.push(`${medal} ${s.teamName} — ${Math.round(s.totalPoints)} pts${delta}`); + sections.push(`${s.rank}. ${s.teamName} — ${Math.round(s.totalPoints)} pts${delta}`); } const MAX_DESCRIPTION = 4096;