Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon:
1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/
mirror windows scored via the placement-group path in processQualifyingEvent,
which took the tie span from the players present on that window's roster
(a draftable subset) instead of the full field. A window holding fewer than
the 8 tied R16 losers split the 9-16 points too few ways and over-awarded.
The tie span is now derived from the canonical tournament_results (the whole
field) for tournament-linked events, falling back to the live count only for
standalone events. Every league now scores identically.
2. Discord notifications rounded QP with Math.round, turning 1.5 into "2".
Both the "Points Awarded" and "QP Standings" values now use a 2-decimal
formatter mirroring the web UI's formatQP, so Discord and the site agree.
3. The Discord "QP Standings" block ranked players only among that event's
scorers, showing two R16 losers as T1 instead of T9. Rank is now computed
over the full season field and passed through to the notifier.
4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored
counter incremented on every fan-out sync with a guard that misfired. It is
now derived on read (getMajorsCompleted = count of completed qualifying
events), which is self-correcting; the increment/decrement writes are removed
along with the now-unused hasProcessedQualifyingPlacement helper.
Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so
already-corrupted seasons are corrected (2 -> 1.5), and threads a
skipNotifications option through processQualifyingEvent / syncTournamentResults
so the backfill does not re-ping every league.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
Widening the tennis-sync gate to fire on any elimination means
notifyQualifyingPointsUpdate now runs on essentially every early-round
sync, since most eliminated players are undrafted. Fetch the draft picks
first and bail before the QP-total, season, participant, user, and Discord
lookups when no drafted participant earned QP or was knocked out this sync —
avoiding a full query battery just to send nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ