2026-03-27 20:45:15 -07:00
|
|
|
|
import { buildTiedRankChecker } from "~/lib/standings-display";
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
interface DiscordEmbed {
|
|
|
|
|
|
title?: string;
|
2026-05-17 23:20:51 -07:00
|
|
|
|
url?: string;
|
2026-03-17 11:16:36 -07:00
|
|
|
|
description?: string;
|
|
|
|
|
|
color?: number;
|
|
|
|
|
|
footer?: { text: string };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface DiscordWebhookPayload {
|
|
|
|
|
|
content?: string;
|
|
|
|
|
|
embeds?: DiscordEmbed[];
|
2026-05-15 10:06:54 -07:00
|
|
|
|
allowed_mentions?: { parse: string[]; users: string[] };
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 16:24:22 +00:00
|
|
|
|
// Per-league serial promise chain: serializes pick notifications within a league to
|
2026-05-27 16:41:08 +00:00
|
|
|
|
// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains.
|
2026-05-29 16:24:22 +00:00
|
|
|
|
const leagueChains = new Map<string, Promise<void>>();
|
2026-05-27 16:41:08 +00:00
|
|
|
|
|
|
|
|
|
|
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
|
2026-05-29 16:24:22 +00:00
|
|
|
|
const prev = leagueChains.get(leagueId) ?? Promise.resolve();
|
|
|
|
|
|
const next = prev.then(() => fn().catch(() => {}));
|
|
|
|
|
|
leagueChains.set(leagueId, next);
|
|
|
|
|
|
// Clean up the map entry once the chain settles, but only if no newer pick
|
|
|
|
|
|
// was enqueued after this one (reference equality guards against that race).
|
|
|
|
|
|
next.then(() => { if (leagueChains.get(leagueId) === next) leagueChains.delete(leagueId); });
|
2026-05-27 16:41:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
export async function sendDiscordWebhook(
|
|
|
|
|
|
webhookUrl: string,
|
|
|
|
|
|
payload: DiscordWebhookPayload
|
|
|
|
|
|
): Promise<void> {
|
2026-05-27 16:41:08 +00:00
|
|
|
|
const body = JSON.stringify(payload);
|
|
|
|
|
|
const headers = { "Content-Type": "application/json" };
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
|
const res = await fetch(webhookUrl, { method: "POST", headers, body });
|
|
|
|
|
|
|
|
|
|
|
|
if (res.status === 429) {
|
|
|
|
|
|
// Cap the total wait at 10 s so awaited callers (e.g. settings actions) are
|
|
|
|
|
|
// never hung for a full Discord global-rate-limit window. Use || 1 instead of
|
|
|
|
|
|
// ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default.
|
|
|
|
|
|
// Multiply by (attempt + 1) for progressive backoff, then cap the product.
|
|
|
|
|
|
const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, wait * 1000));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-17 11:16:36 -07:00
|
|
|
|
|
2026-05-27 16:41:08 +00:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const text = await res.text().catch(() => "");
|
|
|
|
|
|
throw new Error(`Discord webhook failed: ${res.status} ${text}`);
|
2026-05-24 21:29:02 -07:00
|
|
|
|
}
|
2026-05-27 16:41:08 +00:00
|
|
|
|
|
2026-05-24 21:29:02 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 16:41:08 +00:00
|
|
|
|
throw new Error("Discord webhook failed after 3 retries (rate limited)");
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
/** Escape characters that trigger Discord markdown formatting. */
|
|
|
|
|
|
function escapeMarkdown(text: string): string {
|
|
|
|
|
|
return text.replace(/[_*~`|\\]/g, "\\$&");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 21:40:11 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
|
|
|
|
|
|
* earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly
|
2026-07-09 06:34:25 +00:00
|
|
|
|
* and fractional values to at most 2 decimals with trailing zeros trimmed (1.50→1.5).
|
|
|
|
|
|
* Mirrors the web UI's formatQP (app/components/scoring/QualifyingPointsStandings.tsx)
|
|
|
|
|
|
* so Discord and the site agree.
|
2026-07-03 21:40:11 +00:00
|
|
|
|
*/
|
|
|
|
|
|
function formatQPValue(n: number): string {
|
2026-07-09 06:34:25 +00:00
|
|
|
|
return parseFloat(n.toFixed(2)).toString();
|
2026-07-03 21:40:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
export interface StandingEntry {
|
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
|
|
|
|
teamId: string;
|
|
|
|
|
|
teamName: string;
|
2026-03-17 14:34:09 -07:00
|
|
|
|
username?: string;
|
2026-05-15 10:06:54 -07:00
|
|
|
|
discordUserId?: string;
|
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
|
|
|
|
totalPoints: number;
|
2026-03-17 11:16:36 -07:00
|
|
|
|
rank: number;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
export interface ScoredMatch {
|
|
|
|
|
|
winnerName: string;
|
|
|
|
|
|
loserName: string;
|
2026-03-17 14:34:09 -07:00
|
|
|
|
winnerUsername?: string;
|
|
|
|
|
|
loserUsername?: string;
|
2026-05-15 10:06:54 -07:00
|
|
|
|
winnerDiscordUserId?: string;
|
|
|
|
|
|
loserDiscordUserId?: string;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 19:36:33 +00:00
|
|
|
|
export interface EliminatedTeam {
|
|
|
|
|
|
participantName: string;
|
|
|
|
|
|
username?: string;
|
|
|
|
|
|
discordUserId?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
export async function sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
seasonName,
|
|
|
|
|
|
standings,
|
|
|
|
|
|
previousStandings,
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousRanks,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
sportName,
|
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
|
|
|
|
eventName,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
scoredMatches,
|
2026-06-28 19:36:33 +00:00
|
|
|
|
eliminatedTeams,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}: {
|
|
|
|
|
|
webhookUrl: string;
|
|
|
|
|
|
seasonName: string;
|
|
|
|
|
|
standings: StandingEntry[];
|
|
|
|
|
|
previousStandings: Map<string, number>;
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousRanks?: Map<string, number>;
|
2026-03-17 11:16:36 -07:00
|
|
|
|
sportName?: string;
|
|
|
|
|
|
eventName?: string;
|
|
|
|
|
|
scoredMatches?: ScoredMatch[];
|
2026-06-28 19:36:33 +00:00
|
|
|
|
eliminatedTeams?: EliminatedTeam[];
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}): Promise<void> {
|
|
|
|
|
|
const sections: string[] = [];
|
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
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
// Header: "Sport Name — Event Name"
|
|
|
|
|
|
if (sportName || eventName) {
|
|
|
|
|
|
const parts = [sportName, eventName].filter(Boolean);
|
|
|
|
|
|
sections.push(`**${parts.join(" — ")}**`);
|
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
|
|
|
|
}
|
2026-03-17 11:16:36 -07:00
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
// Scored matches section — only show matches where at least one manager
|
|
|
|
|
|
// (fantasy team owner) scored Brackt points or had a team eliminated.
|
|
|
|
|
|
const relevantMatches = scoredMatches?.filter(
|
|
|
|
|
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
|
|
|
|
|
);
|
|
|
|
|
|
if (relevantMatches && relevantMatches.length > 0) {
|
2026-03-17 12:34:10 -07:00
|
|
|
|
sections.push("\n**Scored Matches**");
|
2026-03-19 15:52:57 -07:00
|
|
|
|
for (const match of relevantMatches) {
|
2026-05-15 10:06:54 -07:00
|
|
|
|
const winnerManagerLabel = match.winnerDiscordUserId
|
|
|
|
|
|
? `<@${match.winnerDiscordUserId}>`
|
|
|
|
|
|
: match.winnerUsername
|
|
|
|
|
|
? escapeMarkdown(match.winnerUsername)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const loserManagerLabel = match.loserDiscordUserId
|
|
|
|
|
|
? `<@${match.loserDiscordUserId}>`
|
|
|
|
|
|
: match.loserUsername
|
|
|
|
|
|
? escapeMarkdown(match.loserUsername)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const winnerLabel = winnerManagerLabel
|
|
|
|
|
|
? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})`
|
2026-03-19 15:52:57 -07:00
|
|
|
|
: escapeMarkdown(match.winnerName);
|
2026-05-15 10:06:54 -07:00
|
|
|
|
const loserLabel = loserManagerLabel
|
|
|
|
|
|
? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
|
2026-03-19 15:52:57 -07:00
|
|
|
|
: escapeMarkdown(match.loserName);
|
|
|
|
|
|
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 19:36:33 +00:00
|
|
|
|
// Eliminated teams section — drafted teams knocked out when a bracket/knockout
|
|
|
|
|
|
// was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage
|
|
|
|
|
|
// losers). These produce no score change, so they wouldn't appear above.
|
|
|
|
|
|
if (eliminatedTeams && eliminatedTeams.length > 0) {
|
|
|
|
|
|
sections.push("\n**Eliminated**");
|
|
|
|
|
|
for (const team of eliminatedTeams) {
|
|
|
|
|
|
const managerLabel = team.discordUserId
|
|
|
|
|
|
? `<@${team.discordUserId}>`
|
|
|
|
|
|
: team.username
|
|
|
|
|
|
? escapeMarkdown(team.username)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const label = managerLabel
|
|
|
|
|
|
? `${escapeMarkdown(team.participantName)} (${managerLabel})`
|
|
|
|
|
|
: escapeMarkdown(team.participantName);
|
|
|
|
|
|
sections.push(`• **${label}**`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 20:45:15 -07:00
|
|
|
|
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
|
|
|
|
|
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
|
|
|
|
|
|
|
2026-07-04 22:02:06 -07:00
|
|
|
|
// A team's points genuinely changed this event (vs. merely being displaced in
|
|
|
|
|
|
// rank because someone else scored). Only real scorers are pinged; rank-only
|
|
|
|
|
|
// shufflers are still displayed, but by name and without an @-mention.
|
|
|
|
|
|
const pointsChanged = (s: StandingEntry) => {
|
|
|
|
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
|
|
|
|
return prevPoints !== undefined && prevPoints !== s.totalPoints;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
// Standings changes section — show teams whose points or rank changed.
|
|
|
|
|
|
const changedTeams = standings.filter((s) => {
|
|
|
|
|
|
const prevRank = previousRanks?.get(s.teamId);
|
|
|
|
|
|
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
2026-07-04 22:02:06 -07:00
|
|
|
|
return pointsChanged(s) || rankChanged;
|
2026-03-19 15:52:57 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (changedTeams.length > 0) {
|
|
|
|
|
|
sections.push("\n**Standings Changes**");
|
|
|
|
|
|
for (const s of changedTeams) {
|
2026-03-27 20:45:15 -07:00
|
|
|
|
const rankPrefix = rankLabel(s.rank);
|
2026-03-19 15:52:57 -07:00
|
|
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
|
|
|
|
let pointDelta = "";
|
|
|
|
|
|
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
|
|
|
|
|
|
const diff = Math.round(s.totalPoints - prevPoints);
|
|
|
|
|
|
const sign = diff > 0 ? "+" : "";
|
|
|
|
|
|
pointDelta = ` **(${sign}${diff} pts)**`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rankDelta = "";
|
|
|
|
|
|
if (previousRanks) {
|
|
|
|
|
|
const prevRank = previousRanks.get(s.teamId);
|
|
|
|
|
|
if (prevRank !== undefined && prevRank !== s.rank) {
|
|
|
|
|
|
const moved = prevRank - s.rank; // positive = moved up
|
|
|
|
|
|
rankDelta = moved > 0 ? ` ↑${moved}` : ` ↓${Math.abs(moved)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const escapedName = escapeMarkdown(s.teamName);
|
2026-07-04 22:02:06 -07:00
|
|
|
|
const managerLabel = s.discordUserId && pointsChanged(s)
|
2026-05-15 10:06:54 -07:00
|
|
|
|
? `<@${s.discordUserId}>`
|
|
|
|
|
|
: s.username
|
|
|
|
|
|
? escapeMarkdown(s.username)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName;
|
2026-03-27 20:45:15 -07:00
|
|
|
|
sections.push(`${rankPrefix}\\. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_DESCRIPTION = 4096;
|
|
|
|
|
|
let description = sections.join("\n");
|
|
|
|
|
|
if (description.length > MAX_DESCRIPTION) {
|
|
|
|
|
|
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 10:06:54 -07:00
|
|
|
|
// Collect Discord user IDs of all opted-in managers appearing in this notification.
|
|
|
|
|
|
const pingUserIds = new Set<string>();
|
|
|
|
|
|
for (const s of changedTeams) {
|
2026-07-04 22:02:06 -07:00
|
|
|
|
if (s.discordUserId && pointsChanged(s)) pingUserIds.add(s.discordUserId);
|
2026-05-15 10:06:54 -07:00
|
|
|
|
}
|
|
|
|
|
|
for (const m of relevantMatches ?? []) {
|
|
|
|
|
|
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
|
|
|
|
|
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
|
|
|
|
|
|
}
|
2026-06-28 19:36:33 +00:00
|
|
|
|
for (const t of eliminatedTeams ?? []) {
|
|
|
|
|
|
if (t.discordUserId) pingUserIds.add(t.discordUserId);
|
|
|
|
|
|
}
|
2026-05-15 10:06:54 -07:00
|
|
|
|
const pingIds = [...pingUserIds];
|
|
|
|
|
|
|
|
|
|
|
|
const payload: DiscordWebhookPayload = {
|
2026-03-17 11:16:36 -07:00
|
|
|
|
embeds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: `📊 Standings Update — ${seasonName}`,
|
|
|
|
|
|
description,
|
2026-07-01 17:29:45 +00:00
|
|
|
|
color: 0xffd700, // Gold
|
2026-03-17 11:16:36 -07:00
|
|
|
|
footer: { text: "brackt.com" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2026-05-15 10:06:54 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (pingIds.length > 0) {
|
|
|
|
|
|
// Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload.
|
|
|
|
|
|
const cappedIds = pingIds.slice(0, 100);
|
|
|
|
|
|
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
|
|
|
|
|
|
payload.allowed_mentions = { parse: [], users: cappedIds };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await sendDiscordWebhook(webhookUrl, payload);
|
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
|
|
|
|
}
|
2026-05-17 23:20:51 -07:00
|
|
|
|
|
2026-07-01 17:29:45 +00:00
|
|
|
|
export interface QPEventEntry {
|
|
|
|
|
|
participantName: string;
|
|
|
|
|
|
qpEarned: number;
|
|
|
|
|
|
qpTotal: number;
|
2026-07-03 21:40:11 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* The participant's rank in the FULL season QP standings (all participants), not
|
|
|
|
|
|
* their position among this event's scorers. Computed by the caller so the "QP
|
|
|
|
|
|
* Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP
|
|
|
|
|
|
* show as T9 (8 players ahead) rather than T1 among just the two of them.
|
|
|
|
|
|
*/
|
|
|
|
|
|
globalRank: number;
|
|
|
|
|
|
/** True when another participant in the full field shares this globalRank. */
|
|
|
|
|
|
globalRankTied: boolean;
|
2026-07-01 17:29:45 +00:00
|
|
|
|
ownerUsername?: string;
|
|
|
|
|
|
ownerDiscordUserId?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
|
|
|
|
|
|
export interface QPEliminatedEntry {
|
|
|
|
|
|
participantName: string;
|
|
|
|
|
|
ownerUsername?: string;
|
|
|
|
|
|
ownerDiscordUserId?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 17:29:45 +00:00
|
|
|
|
export async function sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
seasonName,
|
|
|
|
|
|
sportName,
|
|
|
|
|
|
eventName,
|
|
|
|
|
|
entries,
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
eliminated = [],
|
2026-07-09 10:04:22 -07:00
|
|
|
|
scoreboard = [],
|
2026-07-09 05:12:40 +00:00
|
|
|
|
standingsUrl,
|
2026-07-01 17:29:45 +00:00
|
|
|
|
}: {
|
|
|
|
|
|
webhookUrl: string;
|
|
|
|
|
|
seasonName: string;
|
|
|
|
|
|
sportName?: string;
|
|
|
|
|
|
eventName?: string;
|
|
|
|
|
|
entries: QPEventEntry[];
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
eliminated?: QPEliminatedEntry[];
|
2026-07-09 10:04:22 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The full current scoreboard for the league — every drafted participant, not just
|
2026-07-09 23:22:24 -07:00
|
|
|
|
* those whose QP changed this sync. Drives the "Drafted Participants" standings section.
|
|
|
|
|
|
* `entries`/`eliminated` remain scoped to this sync's changes and drive the
|
2026-07-09 10:04:22 -07:00
|
|
|
|
* "Points Awarded"/"Knocked Out" sections and the ping list.
|
|
|
|
|
|
*/
|
|
|
|
|
|
scoreboard?: QPEventEntry[];
|
2026-07-09 05:12:40 +00:00
|
|
|
|
standingsUrl?: string;
|
2026-07-01 17:29:45 +00:00
|
|
|
|
}): Promise<void> {
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
if (entries.length === 0 && eliminated.length === 0) return;
|
2026-07-01 17:29:45 +00:00
|
|
|
|
|
|
|
|
|
|
const sections: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (sportName || eventName) {
|
|
|
|
|
|
const parts = [sportName, eventName].filter(Boolean);
|
|
|
|
|
|
sections.push(`**${parts.join(" — ")}**`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const awardedEntries = entries
|
|
|
|
|
|
.filter((e) => e.qpEarned > 0)
|
|
|
|
|
|
.toSorted((a, b) => b.qpEarned - a.qpEarned);
|
|
|
|
|
|
|
|
|
|
|
|
if (awardedEntries.length > 0) {
|
|
|
|
|
|
sections.push("\n**Points Awarded**");
|
|
|
|
|
|
for (const e of awardedEntries) {
|
|
|
|
|
|
const managerLabel = e.ownerDiscordUserId
|
|
|
|
|
|
? `<@${e.ownerDiscordUserId}>`
|
|
|
|
|
|
: e.ownerUsername
|
|
|
|
|
|
? escapeMarkdown(e.ownerUsername)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const label = managerLabel
|
|
|
|
|
|
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
|
|
|
|
|
: escapeMarkdown(e.participantName);
|
2026-07-09 05:12:40 +00:00
|
|
|
|
sections.push(`• **${label}** — ${formatQPValue(e.qpEarned)} QP`);
|
2026-07-01 17:29:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
// Knocked-out section — drafted players eliminated this sync in a non-scoring
|
|
|
|
|
|
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
|
|
|
|
|
|
if (eliminated.length > 0) {
|
|
|
|
|
|
sections.push("\n**Knocked Out**");
|
|
|
|
|
|
for (const e of eliminated) {
|
|
|
|
|
|
const managerLabel = e.ownerDiscordUserId
|
|
|
|
|
|
? `<@${e.ownerDiscordUserId}>`
|
|
|
|
|
|
: e.ownerUsername
|
|
|
|
|
|
? escapeMarkdown(e.ownerUsername)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const label = managerLabel
|
|
|
|
|
|
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
|
|
|
|
|
: escapeMarkdown(e.participantName);
|
|
|
|
|
|
sections.push(`• ${label}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 23:22:24 -07:00
|
|
|
|
// Drafted Participants: every drafted participant that has actually scored (qpTotal > 0),
|
|
|
|
|
|
// drawn from the FULL drafted field (`scoreboard`) not just this sync's movers, so it reads
|
|
|
|
|
|
// as a live standings snapshot. Rendered as ranked lines ordered by full-season standing. A
|
|
|
|
|
|
// "Points Bubble" divider marks the cutoff between those currently in the points (rank <= 8)
|
|
|
|
|
|
// and those below it (rank >= 9). Participants with 0 QP are omitted entirely. Never pinged,
|
|
|
|
|
|
// so managers are shown by plain username, never as a <@id> mention.
|
|
|
|
|
|
const scored = [...scoreboard]
|
|
|
|
|
|
.filter((e) => e.qpTotal > 0)
|
2026-07-09 05:12:40 +00:00
|
|
|
|
.toSorted((a, b) => a.globalRank - b.globalRank);
|
2026-07-01 17:29:45 +00:00
|
|
|
|
|
2026-07-09 23:22:24 -07:00
|
|
|
|
// Skip the section entirely when no drafted participant has scored (e.g. a sync that only
|
|
|
|
|
|
// reported knockouts) so we don't emit an empty header.
|
|
|
|
|
|
if (scored.length > 0) {
|
|
|
|
|
|
sections.push("\n**Drafted Participants**");
|
|
|
|
|
|
// Insert the divider once, before the first below-the-cutoff (rank >= 9) row. `>= 9`
|
|
|
|
|
|
// (not `> 8`) keeps a tie AT rank 8 above the bubble ("top 8 plus ties"). Only emit it
|
|
|
|
|
|
// after at least one above-the-bubble row exists: globalRank is a season-wide rank while
|
|
|
|
|
|
// this list is scoped to one league's drafts, so a league can have drafted nobody in the
|
|
|
|
|
|
// global top 8 — guarding on rowsAbove avoids a leading divider with nothing above it.
|
|
|
|
|
|
let bubbleInserted = false;
|
|
|
|
|
|
let rowsAbove = 0;
|
|
|
|
|
|
for (const e of scored) {
|
|
|
|
|
|
if (!bubbleInserted && rowsAbove > 0 && e.globalRank >= 9) {
|
|
|
|
|
|
sections.push("**═══ Points Bubble ═══**");
|
|
|
|
|
|
bubbleInserted = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.globalRank <= 8) rowsAbove++;
|
2026-07-03 21:40:11 +00:00
|
|
|
|
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
2026-07-09 05:12:40 +00:00
|
|
|
|
const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : "";
|
2026-07-03 21:40:11 +00:00
|
|
|
|
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`);
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
}
|
2026-07-01 17:29:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_DESCRIPTION = 4096;
|
|
|
|
|
|
let description = sections.join("\n");
|
|
|
|
|
|
if (description.length > MAX_DESCRIPTION) {
|
|
|
|
|
|
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 05:12:40 +00:00
|
|
|
|
// Ping only managers who earned points or lost a drafted player this sync —
|
|
|
|
|
|
// never the non-scoring (zeroEntries) managers.
|
2026-07-01 17:29:45 +00:00
|
|
|
|
const pingUserIds = new Set<string>();
|
2026-07-09 05:12:40 +00:00
|
|
|
|
for (const e of [...awardedEntries, ...eliminated]) {
|
2026-07-01 17:29:45 +00:00
|
|
|
|
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
const pingIds = [...pingUserIds];
|
|
|
|
|
|
|
|
|
|
|
|
const payload: DiscordWebhookPayload = {
|
|
|
|
|
|
embeds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: `🏅 Qualifying Points Update — ${seasonName}`,
|
2026-07-09 05:12:40 +00:00
|
|
|
|
url: standingsUrl,
|
2026-07-01 17:29:45 +00:00
|
|
|
|
description,
|
|
|
|
|
|
color: 0x5865f2, // Discord blurple
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (pingIds.length > 0) {
|
|
|
|
|
|
const cappedIds = pingIds.slice(0, 100);
|
|
|
|
|
|
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
|
|
|
|
|
|
payload.allowed_mentions = { parse: [], users: cappedIds };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await sendDiscordWebhook(webhookUrl, payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 19:55:48 -07:00
|
|
|
|
export async function sendPickAnnouncementNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
draftUrl,
|
|
|
|
|
|
pickNumber,
|
|
|
|
|
|
round,
|
|
|
|
|
|
pickInRound,
|
|
|
|
|
|
pickedTeamName,
|
|
|
|
|
|
participantName,
|
|
|
|
|
|
sportName,
|
|
|
|
|
|
nextTeamName,
|
|
|
|
|
|
nextOwnerDiscordId,
|
|
|
|
|
|
isDraftComplete,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
webhookUrl: string;
|
|
|
|
|
|
draftUrl: string;
|
|
|
|
|
|
pickNumber: number;
|
|
|
|
|
|
round: number;
|
|
|
|
|
|
pickInRound: number;
|
|
|
|
|
|
pickedTeamName: string;
|
|
|
|
|
|
participantName: string;
|
|
|
|
|
|
sportName: string;
|
|
|
|
|
|
nextTeamName?: string;
|
|
|
|
|
|
nextOwnerDiscordId?: string;
|
|
|
|
|
|
isDraftComplete: boolean;
|
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
|
const lines: string[] = [
|
|
|
|
|
|
`**${escapeMarkdown(pickedTeamName)}** selected **${escapeMarkdown(participantName)}** (${escapeMarkdown(sportName)})`,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if (isDraftComplete) {
|
|
|
|
|
|
lines.push("", "The draft is complete!");
|
|
|
|
|
|
} else if (nextTeamName) {
|
|
|
|
|
|
const nextLabel = nextOwnerDiscordId
|
|
|
|
|
|
? `<@${nextOwnerDiscordId}>`
|
|
|
|
|
|
: escapeMarkdown(nextTeamName);
|
|
|
|
|
|
lines.push("", `On the clock: **${escapeMarkdown(nextTeamName)}** (${nextLabel})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const description = lines.join("\n");
|
|
|
|
|
|
const payload: DiscordWebhookPayload = {
|
|
|
|
|
|
embeds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: `Pick #${pickNumber} — Round ${round}, Pick ${pickInRound}`,
|
|
|
|
|
|
url: draftUrl,
|
|
|
|
|
|
description,
|
|
|
|
|
|
color: 0x5865f2,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (nextOwnerDiscordId && !isDraftComplete) {
|
|
|
|
|
|
payload.content = `<@${nextOwnerDiscordId}>`;
|
|
|
|
|
|
payload.allowed_mentions = { parse: [], users: [nextOwnerDiscordId] };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await sendDiscordWebhook(webhookUrl, payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 23:20:51 -07:00
|
|
|
|
export async function sendDraftOrderNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
leagueName,
|
|
|
|
|
|
leagueUrl,
|
|
|
|
|
|
method,
|
|
|
|
|
|
teams,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
webhookUrl: string;
|
|
|
|
|
|
leagueName: string;
|
|
|
|
|
|
leagueUrl: string;
|
|
|
|
|
|
method: "manual" | "randomized";
|
|
|
|
|
|
teams: Array<{ name: string; position: number; username?: string }>;
|
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
|
const title =
|
|
|
|
|
|
method === "manual"
|
|
|
|
|
|
? `Draft Order Manually Set — ${leagueName}`
|
|
|
|
|
|
: `Draft Order Randomized — ${leagueName}`;
|
|
|
|
|
|
|
2026-05-20 19:55:48 -07:00
|
|
|
|
const sorted = teams.toSorted((a, b) => a.position - b.position);
|
2026-05-17 23:20:51 -07:00
|
|
|
|
let description = sorted
|
|
|
|
|
|
.map((t) => {
|
|
|
|
|
|
const teamLabel = escapeMarkdown(t.name);
|
|
|
|
|
|
const usernameLabel = t.username ? ` (${escapeMarkdown(t.username)})` : "";
|
|
|
|
|
|
return `${t.position}. ${teamLabel}${usernameLabel}`;
|
|
|
|
|
|
})
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
if (description.length > 4096) {
|
|
|
|
|
|
description = description.slice(0, 4093) + "...";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await sendDiscordWebhook(webhookUrl, {
|
|
|
|
|
|
embeds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
title,
|
|
|
|
|
|
url: leagueUrl,
|
|
|
|
|
|
description,
|
|
|
|
|
|
color: 0x5865f2,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|