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;