brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts

1177 lines
45 KiB
TypeScript
Raw Normal View History

import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
import { findSportsSeasonById } from "~/models/sports-season";
Canonical tournament layer: schema + backfill (1/2) (#365) * refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit 66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit 775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
generateBracketFromTemplate,
setMatchWinner,
advanceWinnerTemplate,
findPlayoffMatchById,
assignParticipantsToKnockout,
doesLoserAdvance,
} from "~/models/playoff-match";
Add playoff match game scheduling and odds management (#135) * Add playoff match games and odds storage Introduces two new tables for bracket matchup detail storage: - `playoff_match_games`: tracks individual game schedules within a series matchup (game number, scheduledAt, status, per-game scores, winner). Supports scheduled/complete/postponed status enum. - `playoff_match_odds`: stores moneyline odds per participant per matchup (single upsert record, no isLatest complexity). Includes: - Drizzle schema + relations with CASCADE deletes from playoff_matches - Migration 0040_fat_puma.sql - playoff-match-game.ts model with pure helpers: computeSeriesScore, isSeriesComplete, getSeriesLeader — plus full CRUD - playoff-match-odds.ts model with pure helpers: americanToImpliedProbability, impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete - findPlayoffMatchesByEventId and findPlayoffMatchById updated to include games and odds in their query results - Bracket server route: add-game, update-game, delete-game, upsert-odds, delete-odds actions - Bracket admin UI: expandable per-match panel for game schedule management and moneyline odds entry - 41 new unit tests (18 game + 23 odds), all 810 tests passing https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 * Code review fixes: type safety, abstraction, and React correctness - Derive PlayoffMatchGameStatus from schema enum instead of hardcoding the string union, eliminating the duplicate source of truth - updateGame now returns PlayoffMatchGame | undefined to reflect reality when no row matches the ID - Remove TOCTOU check-then-act in update-game action: call updateGame directly and check the return value instead of a pre-flight findGameById - Add status enum validation before the cast in update-game action - Move impliedProbability computation inside upsertMatchOdds so callers only provide moneylineOdds; the model owns the derivation - Remove unnecessary dynamic import of americanToImpliedProbability in the upsert-odds action (was already imported from the same module) - Fix React list reconciliation bug: replace bare <> fragment with <Fragment key={match.id}> so React can correctly track rows https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
import {
createGame,
updateGame,
deleteGame,
type PlayoffMatchGameStatus,
} from "~/models/playoff-match-game";
import {
upsertMatchOdds,
deleteOddsForParticipant,
} from "~/models/playoff-match-odds";
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
import {
processPlayoffEvent,
processMatchResult,
processQualifyingBracketEvent,
processQualifyingEvent,
finalizeQualifyingPoints,
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
recalculateAffectedLeagues,
recalculateStandings,
autoCompleteRoundIfDone,
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
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import {
setParticipantResult,
findParticipantResultsBySportsSeasonId,
deleteParticipantResultsBySportsSeasonId,
} from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings";
import {
createGroupsForEvent,
addMembersToGroup,
findGroupsByEventId,
toggleMemberEliminated,
getAdvancingParticipantIds,
getEliminatedParticipantIds,
} from "~/models/tournament-group";
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
import {
createGroupStageMatches,
generateRoundRobinPairings,
updateGroupStageMatchResult,
updateGroupStageMatchSchedule,
findMatchesByGroupId,
} from "~/models/group-stage-match";
import { logger } from "~/lib/logger";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
if (!sportsSeason) {
throw new Response("Sports season not found", { status: 404 });
}
const event = await getScoringEventById(params.eventId);
if (!event) {
throw new Response("Event not found", { status: 404 });
}
if (event.eventType !== "playoff_game" && event.eventType !== "major_tournament") {
throw new Response("This event is not a playoff event", { status: 400 });
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const matches = await findPlayoffMatchesByEventId(params.eventId);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
// Fetch tournament groups with their round-robin matches
const tournamentGroupsRaw = await findGroupsByEventId(params.eventId);
const tournamentGroups = await Promise.all(
tournamentGroupsRaw.map(async (group) => ({
...group,
groupMatches: await findMatchesByGroupId(group.id),
}))
);
// The matches already include participant relations from the model query
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
},
event,
participants,
matches: matches as Array<typeof matches[0] & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
loser: { id: string; name: string } | null;
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
games: Array<{ id: string; gameNumber: number; scheduledAt: Date | null; status: string; winner?: { name: string } | null }>;
odds: Array<{ id: string; participantId: string; moneylineOdds: number; impliedProbability: string; oddsSource?: string | null; participant: { id: string; name: string } | null }>;
}>,
tournamentGroups,
};
}
/**
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
* guaranteed-minimum QP from the bracket and refresh affected league standings.
*
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
* writes seasonParticipantResults and never records team_score_events match
* results surface via the QP Standings and the Discord standings update. Final
* fantasy placements come from finalizeQualifyingPoints across all majors.
*/
async function scoreQualifyingBracket(
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
event: {
id: string;
sportsSeasonId: string;
name: string | null;
isPrimary: boolean;
tournamentId: string | null;
},
db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> {
await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues(
event.sportsSeasonId,
db,
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
);
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
// If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet.
await fanOutMajorIfPrimary(event, { markComplete: false });
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
// Brackets are built/scored only on the major's primary window. A
// tournament-linked, non-primary event is a read-only mirror that receives
// results via fan-out, so reject every mutating action here.
{
const ev = await getScoringEventById(params.eventId);
if (ev && isReadOnlySibling(ev)) {
return {
error:
"This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.",
};
}
}
if (intent === "generate-bracket") {
const templateId = formData.get("templateId");
if (typeof templateId !== "string" || !templateId) {
return { error: "Template ID is required" };
}
const template = getBracketTemplate(templateId);
if (!template) {
return { error: "Invalid bracket template" };
}
// Parse per-event region config for NCAA-style brackets
let regionOverride: BracketRegion[] | undefined;
if (template.regions && template.regions.length > 0) {
const regions: BracketRegion[] = [];
for (let r = 0; r < template.regions.length; r++) {
const name = (formData.get(`regionName${r}`) as string | null)?.trim()
|| `Region ${r + 1}`;
const seedsRaw = (formData.get(`regionPlayInSeeds${r}`) as string | null) || "";
const playInSeeds = seedsRaw
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n) && n >= 1 && n <= 16);
const playInSet = new Set(playInSeeds);
regions.push({
name,
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
playIns: playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
});
}
// Validate total = template.totalTeams
const total = regions.reduce(
(sum, r) => sum + r.directSeeds.length + r.playIns.length * 2,
0
);
if (total !== template.totalTeams) {
return {
error: `Region config accounts for ${total} team slots but template requires ${template.totalTeams}`,
};
}
regionOverride = regions;
}
const participantIds: string[] = [];
for (let i = 0; i < template.totalTeams; i++) {
const participantId = formData.get(`participant${i}`);
if (typeof participantId !== "string" || !participantId) {
return { error: `Participant ${i + 1} is required` };
}
participantIds.push(participantId);
}
// Check for duplicates
const uniqueParticipants = new Set(participantIds);
if (uniqueParticipants.size !== participantIds.length) {
return { error: "Each participant can only be selected once" };
}
try {
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
const event = await getScoringEventById(params.eventId);
if (event) {
// Get all participants in the sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Create a set of participants in the bracket for fast lookup
const participantsInBracket = new Set(participantIds);
// Mark participants NOT in the bracket as eliminated
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated
);
}
}
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
}
// Update the event to store the template ID, scoring start round, and region config
await updateScoringEvent(params.eventId, {
bracketTemplateId: templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
bracketRegionConfig: regionOverride,
});
return { success: "Bracket generated successfully" };
} catch (error) {
logger.error("Error generating bracket:", error);
return {
error:
error instanceof Error
? error.message
: "Failed to generate bracket",
};
}
}
if (intent === "set-winner") {
const matchId = formData.get("matchId");
const winnerId = formData.get("winnerId");
if (typeof matchId !== "string" || !matchId) {
return { error: "Match ID is required" };
}
if (typeof winnerId !== "string" || !winnerId) {
return { error: "Winner ID is required" };
}
try {
const match = await findPlayoffMatchById(matchId);
if (!match) {
return { error: "Match not found" };
}
// Get the event to determine the template
const event = await getScoringEventById(match.scoringEventId);
if (!event) {
return { error: "Event not found" };
}
// Determine loser
const loserId =
match.participant1Id === winnerId
? match.participant2Id
: match.participant1Id;
if (!loserId) {
return { error: "Could not determine loser" };
}
// Set the winner
await setMatchWinner(matchId, winnerId, loserId);
// Try to advance the winner to the next round using template
Fix bracket scoring logic to prefer template over DB defaults (#298) * Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 11:27:00 -07:00
const setWinnerTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
if (setWinnerTemplate) {
try {
await advanceWinnerTemplate(matchId, winnerId, setWinnerTemplate);
} catch (error) {
// Only ignore "already filled" errors, re-throw unexpected errors
if (
error instanceof Error &&
error.message.includes("already filled")
) {
logger.warn("Match already filled, skipping advancement");
} else {
throw error; // Re-throw unexpected errors
}
}
}
const db = database();
if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(event, db, {
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
});
} else {
// Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true).
// Prefer the template-defined isScoring over the DB field: the DB column
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
}
return { success: "Winner set successfully" };
} catch (error) {
logger.error("Error setting winner:", error);
return {
error:
error instanceof Error ? error.message : "Failed to set winner",
};
}
}
if (intent === "set-round-winners") {
const round = formData.get("round");
if (typeof round !== "string" || !round) {
return { error: "Round is required" };
}
try {
// Get all winner assignments from form data
const winnerAssignments: Array<{ matchId: string; winnerId: string }> = [];
for (const [key, value] of formData.entries()) {
if (key.startsWith("winner-") && typeof value === "string") {
const matchId = key.replace("winner-", "");
winnerAssignments.push({ matchId, winnerId: value });
}
}
if (winnerAssignments.length === 0) {
return { error: "No winners selected" };
}
// Get the event to determine the template
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
Fix bracket scoring logic to prefer template over DB defaults (#298) * Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 11:27:00 -07:00
// Prefer template-defined isScoring over the DB field (DB defaults to true,
// so legacy play-in rows may be incorrectly marked as scoring).
const roundIsScoring = new Map<string, boolean>(
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
);
// Process each winner assignment
let successCount = 0;
const errors: string[] = [];
const processedMatchIds: string[] = [];
for (const { matchId, winnerId } of winnerAssignments) {
try {
const match = await findPlayoffMatchById(matchId);
if (!match) {
errors.push(`Match ${matchId} not found`);
continue;
}
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
// Guard: ensure the match actually belongs to the submitted round.
if (match.round !== round) {
errors.push(`Match ${match.matchNumber} is in round "${match.round}", not "${round}"`);
continue;
}
// Determine loser
const loserId =
match.participant1Id === winnerId
? match.participant2Id
: match.participant1Id;
if (!loserId) {
errors.push(`Could not determine loser for match ${match.matchNumber}`);
continue;
}
// Set the winner
await setMatchWinner(matchId, winnerId, loserId);
// Try to advance the winner to the next round using template
if (template) {
try {
await advanceWinnerTemplate(matchId, winnerId, template);
} catch (error) {
// Only ignore "already filled" errors
if (
error instanceof Error &&
error.message.includes("already filled")
) {
logger.warn("Match already filled, skipping advancement");
} else {
throw error;
}
}
}
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
// Score this match without triggering standings/probability recalc yet —
// we batch those side effects into a single call after the loop.
// Qualifying majors derive QP from the whole bracket once after the loop
// (processQualifyingBracketEvent), so skip the per-match fantasy scoring here.
if (!event.isQualifyingEvent) {
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
skipSideEffects: true,
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
});
}
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
successCount++;
processedMatchIds.push(matchId);
} catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push(
`Match ${matchId}: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
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
// 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.
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
if (successCount > 0) {
const db = database();
// Qualifying majors: derive QP from the full bracket once for the batch.
if (event.isQualifyingEvent) {
await processQualifyingBracketEvent(event.id, db);
}
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
// when computing projected points.
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
try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
} catch (error) {
logger.error(`Error updating probabilities after batch round winners:`, error);
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
}
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
// autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for
// qualifying majors, which are scored via processQualifyingBracketEvent above.
if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
} else {
// Shared major primary window: propagate this round to siblings.
await fanOutMajorIfPrimary(event, { markComplete: false });
}
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
}
if (errors.length > 0 && successCount === 0) {
return { error: `Failed to set winners: ${errors.join(", ")}` };
}
if (errors.length > 0) {
return {
success: `Set ${successCount} winner(s) successfully`,
error: `Some errors occurred: ${errors.join(", ")}`,
};
}
return { success: `Successfully set ${successCount} winner(s) for ${round}` };
} catch (error) {
logger.error("Error setting round winners:", error);
return {
error:
error instanceof Error ? error.message : "Failed to set winners",
};
}
}
if (intent === "complete-round") {
const round = formData.get("round");
if (typeof round !== "string" || !round) {
return { error: "Round is required" };
}
try {
// Get the event and update playoffRound to the completed round
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Verify all matches in this round are complete
const matches = await findPlayoffMatchesByEventId(params.eventId);
// Validate that the round exists in this event's matches
const roundMatches = matches.filter((m) => m.round === round);
if (roundMatches.length === 0) {
return { error: `Round "${round}" not found in this bracket` };
}
const allComplete = roundMatches.every((m) => m.isComplete);
if (!allComplete) {
return { error: `Not all matches in ${round} are complete` };
}
// Qualifying majors (e.g. CS2): QP is derived directly from the bracket via
// processQualifyingBracketEvent — there are no seasonParticipantResults rows to
// validate against, and final fantasy placements come from finalizeQualifyingPoints.
if (event.isQualifyingEvent) {
await scoreQualifyingBracket(event, database());
return { success: `${round} completed and qualifying points updated` };
}
// Validate round order: ensure previous rounds are complete
const existingResults = await findParticipantResultsBySportsSeasonId(
params.id
);
if (round === "Finals") {
// Finals requires Semifinals to be complete (3rd/4th place results exist)
const hasSemifinalResults = existingResults.some(
(r) => r.finalPosition === 3 || r.finalPosition === 4
);
if (!hasSemifinalResults) {
return {
error:
"Semifinals must be completed before Finals (no 3rd/4th place results found)",
};
}
} else if (round === "Semifinals") {
// Semifinals requires Quarterfinals to be complete if QF matches exist
const hasQuarterfinals = matches.some((m) => m.round === "Quarterfinals");
if (hasQuarterfinals) {
const hasQuarterfinalsResults = existingResults.some(
(r) =>
r.finalPosition === 5 ||
r.finalPosition === 6 ||
r.finalPosition === 7 ||
r.finalPosition === 8
);
if (!hasQuarterfinalsResults) {
return {
error:
"Quarterfinals must be completed before Semifinals (no 5th-8th place results found)",
};
}
}
}
// Get the sports season to find a fantasy season for scoring rules
const sportsSeason = await findSportsSeasonById(params.id);
if (!sportsSeason) {
return { error: "Sports season not found" };
}
// Process the playoff event to calculate placements
// TODO Phase 2.7: Refactor processPlayoffEvent to use template system
// For now, we still need to set playoffRound for the old scoring logic
// even though we removed it from the create event UI
const db = database();
await db
.update(schema.scoringEvents)
.set({ playoffRound: round, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId));
// Get a fantasy season ID for scoring calculation
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
if (seasonSports.length === 0) {
return {
error: "No fantasy seasons found for this sports season",
};
}
Refactor playoff event processing and improve code clarity (#261) * Remove redundant processPlayoffEvent loop from finalize-bracket action All bracket rounds are already processed (with scoring and Discord notifications) as each match winner is set via set-winner/set-round-winners. By the time the Finalize button is clicked, placements are current and standings are up to date. The re-processing loop was firing recalculations and Discord notifications once per round needlessly. The finalize action now only assigns 0 points to non-bracket participants, marks the event complete, and runs one final standings recalculation. https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 * Fix stale comment in finalize-bracket action The template is now fetched only as a validity guard, not for round order iteration (which was removed in the previous commit). https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 * Fix complete-round redundant Discord/recalculation and stale comment complete-round was calling processPlayoffEvent without skipRecalculate, firing recalculateAffectedLeagues and Discord even though each match winner had already triggered those side effects via set-winner. Add skipRecalculate: true to match the autoCompleteRoundIfDone pattern. Also fix autoCompleteRoundIfDone comment which incorrectly said "non-bracket eliminations are recorded" — that's a separate step in finalize-bracket; processPlayoffEvent records bracket round placements. https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 15:11:43 -07:00
// Process playoff event to update participant results.
// skipRecalculate=true: match winners were already set via set-winner/set-round-winners,
// which triggered recalculateAffectedLeagues and Discord per match. No need to re-fire.
await processPlayoffEvent(params.eventId, undefined, { skipRecalculate: true });
return {
success: `${round} completed and placements calculated successfully`,
};
} catch (error) {
logger.error("Error completing round:", error);
return {
error:
error instanceof Error ? error.message : "Failed to complete round",
};
}
}
if (intent === "reprocess-bracket") {
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
const matches = await findPlayoffMatchesByEventId(params.eventId);
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
// fantasy points under the old path. Delete the stale seasonParticipantResults rows
// (erasing those points), then rebuild correct QP from the bracket. Qualifying sports
// have no legitimate per-major fantasy placements — those come from
// finalizeQualifyingPoints across all majors.
if (event.isQualifyingEvent) {
const db = database();
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
await processQualifyingBracketEvent(params.eventId, db);
// If the season's QP was already finalized, the delete above wiped the final
// placements — recompute them from QP totals so standings aren't left blank.
const sportsSeason = await findSportsSeasonById(params.id);
if (sportsSeason?.qualifyingPointsFinalized) {
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
} else {
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
}
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
// Re-propagate corrected QP to sibling windows (data-correction; not final).
await fanOutMajorIfPrimary(event, { markComplete: false });
return {
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
};
}
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
if (completed.length === 0) {
return { error: "No completed matches to reprocess" };
}
// Delete ALL results for this sports season and rebuild from scratch.
// Only deleting partial rows leaves stale finalized rows that block
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
// Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
Fix bracket scoring logic to prefer template over DB defaults (#298) * Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 11:27:00 -07:00
// Build a round→isScoring map from the template so we use the template as the
// source of truth rather than the DB column (which defaults to true and may be
// wrong for brackets created before the isScoring column was added).
const templateRoundIsScoring = new Map<string, boolean>(
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
const sortedMatches = completed.toSorted((a, b) => {
const ai = roundOrder.indexOf(a.round);
const bi = roundOrder.indexOf(b.round);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
for (const match of sortedMatches) {
New design (#309) * Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00
if (!match.winnerId || !match.loserId) continue;
Fix bracket scoring logic to prefer template over DB defaults (#298) * Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 11:27:00 -07:00
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
await processMatchResult(
{
round: match.round,
New design (#309) * Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00
winnerId: match.winnerId,
loserId: match.loserId,
Fix bracket scoring logic to prefer template over DB defaults (#298) * Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 11:27:00 -07:00
isScoring,
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
New design (#309) * Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00
eventId: event.id,
eventName: event.name ?? undefined,
matchId: match.id,
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
skipSideEffects: true,
Fix NBA Play-In Round 1 loser advancement logic (#296) * Fix NBA Play-In 7/8 loser incorrectly marked as eliminated processPlayoffEvent (called by autoCompleteRoundIfDone when all round matches finish) was marking every non-scoring round loser as eliminated without checking doesLoserAdvance. This caused the 7v8 loser, who should advance to Play-In Round 2, to get finalPosition=0 as soon as the full round completed. Fixes: - processPlayoffEvent now calls doesLoserAdvance per match before writing a 0-pt elimination result, matching the guard already in processMatchResult - recalculate-floors handler now passes loserAdvances to processMatchResult so a full reprocess also respects the loser-advances rule - recalculateAffectedLeagues gains a skipDiscord option; recalculate-floors uses it so clicking the admin "Recalculate Floors" button corrects the bad data without re-announcing results on Discord - Add two tests confirming 7v8 loser is not eliminated and 9v10 loser is https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA * Address code review feedback on Play-In loserAdvances fix - Use outer `round` variable instead of match.round in processPlayoffEvent (they're identical, but consistent with surrounding code) - Add comment at recalculate-floors call site explaining skipDiscord intent - Combine two redundant test cases into one covering all four assertions - Add West conference matches (M3/M4) to test fixture — East-only was insufficient given doesLoserAdvance checks matchNumber 1 & 3 - Add guard test: when bracketTemplateId is null all losers are eliminated, catching any future refactor that drops the field from the DB query https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 09:40:53 -07:00
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
}
);
}
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
// This covers teams that didn't make the playoffs/play-in tournament.
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const bracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!bracketParticipantIds.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0
);
eliminatedCount++;
}
}
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
} catch (error) {
logger.error("Error reprocessing bracket:", error);
return {
error: error instanceof Error ? error.message : "Failed to reprocess bracket",
};
}
}
if (intent === "finalize-bracket") {
try {
// Get the event
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Get all matches
const matches = await findPlayoffMatchesByEventId(params.eventId);
if (matches.length === 0) {
return { error: "No bracket exists for this event" };
}
Refactor playoff event processing and improve code clarity (#261) * Remove redundant processPlayoffEvent loop from finalize-bracket action All bracket rounds are already processed (with scoring and Discord notifications) as each match winner is set via set-winner/set-round-winners. By the time the Finalize button is clicked, placements are current and standings are up to date. The re-processing loop was firing recalculations and Discord notifications once per round needlessly. The finalize action now only assigns 0 points to non-bracket participants, marks the event complete, and runs one final standings recalculation. https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 * Fix stale comment in finalize-bracket action The template is now fetched only as a validity guard, not for round order iteration (which was removed in the previous commit). https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 * Fix complete-round redundant Discord/recalculation and stale comment complete-round was calling processPlayoffEvent without skipRecalculate, firing recalculateAffectedLeagues and Discord even though each match winner had already triggered those side effects via set-winner. Add skipRecalculate: true to match the autoCompleteRoundIfDone pattern. Also fix autoCompleteRoundIfDone comment which incorrectly said "non-bracket eliminations are recorded" — that's a separate step in finalize-bracket; processPlayoffEvent records bracket round placements. https://claude.ai/code/session_01RhQS6FQRh6iYtVNaryCEf5 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 15:11:43 -07:00
// Verify the event has a valid bracket template configured
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
if (!template) {
return { error: "Bracket template not found" };
}
// Verify ALL matches are complete
const incompleteMatches = matches.filter((m) => !m.isComplete);
if (incompleteMatches.length > 0) {
return {
error: `Cannot finalize: ${incompleteMatches.length} match(es) still incomplete`,
};
}
// Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the
// standard qualifying finalizer for THIS event. Do not mark the whole sports season
// completed or recalc fantasy standings — a season spans multiple majors and final
// fantasy placements come from finalizeQualifyingPoints across all of them.
if (event.isQualifyingEvent) {
const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db);
await db
.update(schema.scoringEvents)
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId));
await recalculateAffectedLeagues(event.sportsSeasonId, db, {
eventId: params.eventId,
eventName: event.name ?? undefined,
});
Unify majors: score once, fan out across windows + tennis bracket EV Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
// Finalize: propagate to siblings AND mark every window complete.
await fanOutMajorIfPrimary(event, { markComplete: true });
return { success: "Major bracket finalized — qualifying points awarded." };
}
// Get all participants in this sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Get participants in matches
const participantsInMatches = new Set(
matches.flatMap((m) => [m.participant1Id, m.participant2Id].filter(Boolean))
);
const db = database();
// Assign 0 points to participants not in the bracket (Q20)
for (const participant of allParticipants) {
if (!participantsInMatches.has(participant.id)) {
await setParticipantResult(
participant.id,
params.id,
0 // 0 placement = 0 points
);
}
}
// Mark event as complete
await db
.update(schema.scoringEvents)
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId));
await db
.update(schema.sportsSeasons)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, params.id));
// Recalculate standings for all affected fantasy seasons
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
for (const seasonSport of seasonSports) {
await recalculateStandings(seasonSport.seasonId, db);
await createDailySnapshot(seasonSport.seasonId, db);
}
await maybeResolveCompletedBracktForSportsSeason(params.id, db);
return {
success: `Bracket finalized! All placements calculated and standings updated.`,
};
} catch (error) {
logger.error("Error finalizing bracket:", error);
return {
error:
error instanceof Error ? error.message : "Failed to finalize bracket",
};
}
}
if (intent === "generate-groups") {
const templateId = formData.get("templateId");
if (typeof templateId !== "string" || !templateId) {
return { error: "Template ID is required" };
}
const template = getBracketTemplate(templateId);
if (!template || !template.groupStage) {
return { error: "Invalid template or template has no group stage" };
}
const { groupStage } = template;
const totalTeams = groupStage.groupCount * groupStage.teamsPerGroup;
// Collect participant IDs from form
const participantIds: string[] = [];
for (let i = 0; i < totalTeams; i++) {
const participantId = formData.get(`participant${i}`);
if (typeof participantId !== "string" || !participantId) {
return { error: `Participant ${i + 1} is required` };
}
participantIds.push(participantId);
}
// Check for duplicates
const uniqueParticipants = new Set(participantIds);
if (uniqueParticipants.size !== participantIds.length) {
return { error: "Each participant can only be selected once" };
}
try {
// Update the event with template info
await updateScoringEvent(params.eventId, {
bracketTemplateId: templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
});
// Create tournament groups
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
// Distribute participants into groups (in selection order) and create round-robin matches
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const startIdx = groupIndex * groupStage.teamsPerGroup;
const groupParticipantIds = participantIds.slice(
startIdx,
startIdx + groupStage.teamsPerGroup
);
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
// Create the 6 round-robin match pairings for this group
if (groupParticipantIds.length === 4) {
const pairings = generateRoundRobinPairings(groupParticipantIds);
await createGroupStageMatches(groups[groupIndex].id, pairings);
}
}
// Generate the empty knockout bracket structure
await generateBracketFromTemplate(params.eventId, templateId);
// Eliminate participants from this sport season who are not in any group
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!uniqueParticipants.has(participant.id)) {
await setParticipantResult(participant.id, params.id, 0);
eliminatedCount++;
}
}
return {
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
};
} catch (error) {
logger.error("Error generating groups:", error);
return {
error: error instanceof Error ? error.message : "Failed to generate groups",
};
}
}
if (intent === "toggle-elimination") {
const memberId = formData.get("memberId");
if (typeof memberId !== "string" || !memberId) {
return { error: "Member ID is required" };
}
try {
const updated = await toggleMemberEliminated(memberId);
return {
success: updated.eliminated
? "Team marked as eliminated"
: "Team reinstated",
};
} catch (error) {
logger.error("Error toggling elimination:", error);
return {
error: error instanceof Error ? error.message : "Failed to toggle elimination",
};
}
}
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242) * Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
if (intent === "update-group-match") {
const matchId = formData.get("matchId");
const p1Score = formData.get("participant1Score");
const p2Score = formData.get("participant2Score");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
if (typeof p1Score !== "string" || typeof p2Score !== "string") {
return { error: "Both scores are required" };
}
const s1 = parseInt(p1Score, 10);
const s2 = parseInt(p2Score, 10);
if (isNaN(s1) || isNaN(s2) || s1 < 0 || s2 < 0) {
return { error: "Scores must be non-negative integers" };
}
try {
await updateGroupStageMatchResult(matchId, s1, s2);
return { success: "Match result saved" };
} catch (error) {
logger.error("Error updating group match:", error);
return { error: error instanceof Error ? error.message : "Failed to update match" };
}
}
if (intent === "update-group-match-schedule") {
const matchId = formData.get("matchId");
const scheduledAt = formData.get("scheduledAt");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
try {
const dateValue = typeof scheduledAt === "string" && scheduledAt
? new Date(scheduledAt)
: null;
await updateGroupStageMatchSchedule(matchId, dateValue);
return { success: "Match time saved" };
} catch (error) {
logger.error("Error updating group match schedule:", error);
return { error: error instanceof Error ? error.message : "Failed to update schedule" };
}
}
if (intent === "populate-knockout") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Parse match assignments from form
const assignments: Array<{
matchNumber: number;
slot: "participant1Id" | "participant2Id";
participantId: string;
}> = [];
for (const [key, value] of formData.entries()) {
const matchPattern = /^match-(\d+)-(participant1Id|participant2Id)$/;
const match = key.match(matchPattern);
if (match && typeof value === "string" && value) {
assignments.push({
matchNumber: parseInt(match[1], 10),
slot: match[2] as "participant1Id" | "participant2Id",
participantId: value,
});
}
}
if (assignments.length !== 32) {
return { error: `Expected 32 assignments but got ${assignments.length}` };
}
// Validate all assignments are unique participants
const assignedParticipantIds = new Set(assignments.map((a) => a.participantId));
if (assignedParticipantIds.size !== 32) {
return { error: "All 32 knockout slots must have unique participants" };
}
// Validate all assigned participants are non-eliminated
const advancingIds = new Set(await getAdvancingParticipantIds(params.eventId));
for (const participantId of assignedParticipantIds) {
if (!advancingIds.has(participantId)) {
return { error: "All assigned participants must be non-eliminated group members" };
}
}
// Assign participants to knockout bracket
await assignParticipantsToKnockout(params.eventId, assignments);
// Mark eliminated group participants with finalPosition = 0
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
for (const participantId of eliminatedIds) {
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
logger.log(
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
);
return { success: "Knockout bracket populated successfully" };
} catch (error) {
logger.error("Error populating knockout:", error);
return {
error: error instanceof Error ? error.message : "Failed to populate knockout",
};
}
}
Add playoff match game scheduling and odds management (#135) * Add playoff match games and odds storage Introduces two new tables for bracket matchup detail storage: - `playoff_match_games`: tracks individual game schedules within a series matchup (game number, scheduledAt, status, per-game scores, winner). Supports scheduled/complete/postponed status enum. - `playoff_match_odds`: stores moneyline odds per participant per matchup (single upsert record, no isLatest complexity). Includes: - Drizzle schema + relations with CASCADE deletes from playoff_matches - Migration 0040_fat_puma.sql - playoff-match-game.ts model with pure helpers: computeSeriesScore, isSeriesComplete, getSeriesLeader — plus full CRUD - playoff-match-odds.ts model with pure helpers: americanToImpliedProbability, impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete - findPlayoffMatchesByEventId and findPlayoffMatchById updated to include games and odds in their query results - Bracket server route: add-game, update-game, delete-game, upsert-odds, delete-odds actions - Bracket admin UI: expandable per-match panel for game schedule management and moneyline odds entry - 41 new unit tests (18 game + 23 odds), all 810 tests passing https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 * Code review fixes: type safety, abstraction, and React correctness - Derive PlayoffMatchGameStatus from schema enum instead of hardcoding the string union, eliminating the duplicate source of truth - updateGame now returns PlayoffMatchGame | undefined to reflect reality when no row matches the ID - Remove TOCTOU check-then-act in update-game action: call updateGame directly and check the return value instead of a pre-flight findGameById - Add status enum validation before the cast in update-game action - Move impliedProbability computation inside upsertMatchOdds so callers only provide moneylineOdds; the model owns the derivation - Remove unnecessary dynamic import of americanToImpliedProbability in the upsert-odds action (was already imported from the same module) - Fix React list reconciliation bug: replace bare <> fragment with <Fragment key={match.id}> so React can correctly track rows https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
// ── Game scheduling ──────────────────────────────────────────────────────
if (intent === "add-game") {
const matchId = formData.get("matchId");
const gameNumber = formData.get("gameNumber");
const scheduledAt = formData.get("scheduledAt");
const notes = formData.get("notes");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
if (typeof gameNumber !== "string" || !gameNumber) return { error: "gameNumber is required" };
try {
await createGame({
playoffMatchId: matchId,
gameNumber: parseInt(gameNumber, 10),
scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null,
notes: notes ? (notes as string) : null,
});
return { success: "Game added" };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to add game" };
}
}
if (intent === "update-game") {
const gameId = formData.get("gameId");
const scheduledAt = formData.get("scheduledAt");
const status = formData.get("status");
const participant1Score = formData.get("participant1Score");
const participant2Score = formData.get("participant2Score");
const winnerId = formData.get("winnerId");
const notes = formData.get("notes");
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
const validStatuses: PlayoffMatchGameStatus[] = ["scheduled", "complete", "postponed"];
if (status && !validStatuses.includes(status as PlayoffMatchGameStatus)) {
return { error: `Invalid status: must be one of ${validStatuses.join(", ")}` };
}
try {
const updated = await updateGame(gameId, {
...(scheduledAt !== null && { scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null }),
...(status && { status: status as PlayoffMatchGameStatus }),
...(participant1Score !== null && { participant1Score: (participant1Score as string) || null }),
...(participant2Score !== null && { participant2Score: (participant2Score as string) || null }),
...(winnerId !== null && { winnerId: (winnerId as string) || null }),
...(notes !== null && { notes: (notes as string) || null }),
});
if (!updated) return { error: "Game not found" };
return { success: "Game updated" };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to update game" };
}
}
if (intent === "delete-game") {
const gameId = formData.get("gameId");
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
try {
await deleteGame(gameId);
return { success: "Game deleted" };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to delete game" };
}
}
// ── Odds management ──────────────────────────────────────────────────────
if (intent === "upsert-odds") {
const matchId = formData.get("matchId");
const participantId = formData.get("participantId");
const moneylineOdds = formData.get("moneylineOdds");
const oddsSource = formData.get("oddsSource");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
if (typeof moneylineOdds !== "string" || !moneylineOdds) return { error: "moneylineOdds is required" };
const moneylineInt = parseInt(moneylineOdds, 10);
if (isNaN(moneylineInt)) return { error: "moneylineOdds must be an integer" };
try {
await upsertMatchOdds(matchId, participantId, {
moneylineOdds: moneylineInt,
oddsSource: oddsSource ? (oddsSource as string) : undefined,
});
return { success: "Odds updated" };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to update odds" };
}
}
if (intent === "delete-odds") {
const matchId = formData.get("matchId");
const participantId = formData.get("participantId");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
try {
await deleteOddsForParticipant(matchId, participantId);
return { success: "Odds deleted" };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to delete odds" };
}
}
return { error: "Invalid action" };
}