brackt/app/services/discord.ts

62 lines
1.6 KiB
TypeScript
Raw Normal View History

Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
interface StandingsEntry {
teamId: string;
teamName: string;
totalPoints: number;
rank: number | null;
}
interface SendStandingsUpdateOptions {
webhookUrl: string;
seasonName: string;
standings: StandingsEntry[];
previousStandings: Map<string, number> | Record<string, number>;
eventName?: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
eventName,
}: SendStandingsUpdateOptions): Promise<void> {
const title = eventName
? `📊 ${seasonName} — Standings after ${eventName}`
: `📊 ${seasonName} — Standings Update`;
const getPrev = (teamId: string) =>
previousStandings instanceof Map
? previousStandings.get(teamId)
: previousStandings[teamId];
const lines = standings
.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999))
.map((s) => {
const prev = getPrev(s.teamId);
const diff =
prev !== undefined ? s.totalPoints - prev : null;
const diffStr =
diff !== null && diff !== 0
? diff > 0
? ` (+${diff.toFixed(1)})`
: ` (${diff.toFixed(1)})`
: "";
const rankStr = s.rank != null ? `${s.rank}.` : "-";
return `${rankStr} **${s.teamName}** — ${s.totalPoints.toFixed(1)} pts${diffStr}`;
});
const content = [`**${title}**`, "", ...lines].join("\n");
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(
`Discord webhook returned ${response.status}: ${await response.text()}`
);
}
}