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
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm";
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
import {
|
|
|
|
|
calculateBracketPoints,
|
|
|
|
|
calculatePickPoints,
|
|
|
|
|
type ScoringRules,
|
|
|
|
|
} from "~/models/scoring-rules";
|
|
|
|
|
import {
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
countSharedPlacements,
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
lookupSharedPlacementCount,
|
|
|
|
|
} from "~/models/participant-result";
|
2026-06-16 22:11:01 +00:00
|
|
|
import { findParticipantNamesByIds } from "~/models/season-participant";
|
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
|
|
|
import { logger } from "~/lib/logger";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Low-level primitive: writes one team_score_events row with an explicit pointsDelta.
|
|
|
|
|
* participantIds are captured at write time so attribution is accurate regardless
|
|
|
|
|
* of future match results.
|
|
|
|
|
*
|
|
|
|
|
* When matchId is provided (bracket sports), one row is written per match using
|
|
|
|
|
* a partial unique index on (teamId, seasonId, matchId). When matchId is absent
|
|
|
|
|
* (non-bracket fallback), one row per (teamId, seasonId, scoringEventId) is used.
|
|
|
|
|
*
|
|
|
|
|
* For bracket sports, prefer calling recordMatchScoreEvents instead — it computes
|
|
|
|
|
* the exact per-season delta from scoring rules rather than requiring the caller
|
|
|
|
|
* to supply a pre-computed pointsDelta.
|
|
|
|
|
*/
|
|
|
|
|
export async function recordTeamScoreEvent(
|
|
|
|
|
params: {
|
|
|
|
|
teamId: string;
|
|
|
|
|
seasonId: string;
|
|
|
|
|
scoringEventId: string;
|
|
|
|
|
scoringEventName: string | null;
|
|
|
|
|
sportName: string | null;
|
|
|
|
|
participantIds: string[];
|
|
|
|
|
pointsDelta: number;
|
|
|
|
|
occurredAt?: Date;
|
|
|
|
|
matchId?: string;
|
|
|
|
|
},
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const values = {
|
|
|
|
|
teamId: params.teamId,
|
|
|
|
|
seasonId: params.seasonId,
|
|
|
|
|
scoringEventId: params.scoringEventId,
|
|
|
|
|
scoringEventName: params.scoringEventName,
|
|
|
|
|
sportName: params.sportName,
|
|
|
|
|
matchId: params.matchId ?? null,
|
|
|
|
|
participantIds: params.participantIds,
|
|
|
|
|
pointsDelta: params.pointsDelta.toString(),
|
|
|
|
|
occurredAt: params.occurredAt ?? new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (params.matchId) {
|
|
|
|
|
// Per-match path: unique on (teamId, seasonId, matchId) WHERE matchId IS NOT NULL
|
|
|
|
|
await db
|
|
|
|
|
.insert(schema.teamScoreEvents)
|
|
|
|
|
.values(values)
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [
|
|
|
|
|
schema.teamScoreEvents.teamId,
|
|
|
|
|
schema.teamScoreEvents.seasonId,
|
|
|
|
|
schema.teamScoreEvents.matchId,
|
|
|
|
|
],
|
|
|
|
|
targetWhere: sql`match_id IS NOT NULL`,
|
|
|
|
|
set: {
|
|
|
|
|
participantIds: params.participantIds,
|
|
|
|
|
pointsDelta: params.pointsDelta.toString(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Event-level fallback: unique on (teamId, seasonId, scoringEventId) WHERE matchId IS NULL
|
|
|
|
|
await db
|
|
|
|
|
.insert(schema.teamScoreEvents)
|
|
|
|
|
.values(values)
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [
|
|
|
|
|
schema.teamScoreEvents.teamId,
|
|
|
|
|
schema.teamScoreEvents.seasonId,
|
|
|
|
|
schema.teamScoreEvents.scoringEventId,
|
|
|
|
|
],
|
|
|
|
|
targetWhere: sql`match_id IS NULL`,
|
|
|
|
|
set: {
|
|
|
|
|
participantIds: params.participantIds,
|
|
|
|
|
pointsDelta: params.pointsDelta.toString(),
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
// Refreshed too, so a row written with a stale label can be repaired
|
|
|
|
|
// by a later correct run instead of keeping the wrong name forever.
|
|
|
|
|
scoringEventName: params.scoringEventName,
|
|
|
|
|
sportName: params.sportName,
|
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
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Records a score event for every fantasy season that uses the given sports season,
|
|
|
|
|
* attributing the exact point delta to the specific match winner.
|
|
|
|
|
*
|
|
|
|
|
* Called from processMatchResult immediately after upsertParticipantResult sets the
|
|
|
|
|
* winner's new floor, so the delta is computed from the exact position change rather
|
|
|
|
|
* than from aggregate before/after standings totals.
|
|
|
|
|
*
|
|
|
|
|
* oldFloor is 0 when the participant had no prior result row (first match win).
|
|
|
|
|
*/
|
|
|
|
|
export async function recordMatchScoreEvents(
|
|
|
|
|
params: {
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
oldFloor: number;
|
|
|
|
|
newFloor: number;
|
|
|
|
|
bracketTemplateId: string | null;
|
|
|
|
|
matchId: string;
|
|
|
|
|
eventId: string;
|
|
|
|
|
eventName: string | null;
|
|
|
|
|
},
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
// Fetch sport name for display (one query, shared across all seasons)
|
|
|
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
|
|
|
|
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
|
|
|
|
|
with: { sport: { columns: { name: true } } },
|
|
|
|
|
});
|
|
|
|
|
const sportName = sportsSeason?.sport?.name ?? null;
|
|
|
|
|
|
|
|
|
|
// All fantasy seasons that include this sports season
|
|
|
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
|
|
|
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
|
|
|
|
|
columns: { seasonId: true },
|
|
|
|
|
});
|
|
|
|
|
if (seasonSports.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const seasonIds = seasonSports.map((ss) => ss.seasonId);
|
|
|
|
|
|
|
|
|
|
// Batch fetch: which team in each season drafted this participant?
|
|
|
|
|
const picks = await db.query.draftPicks.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
inArray(schema.draftPicks.seasonId, seasonIds),
|
|
|
|
|
eq(schema.draftPicks.participantId, params.participantId)
|
|
|
|
|
),
|
|
|
|
|
columns: { teamId: true, seasonId: true },
|
|
|
|
|
});
|
|
|
|
|
if (picks.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const teamBySeasonId = new Map(picks.map((p) => [p.seasonId, p.teamId]));
|
|
|
|
|
|
|
|
|
|
// Batch fetch scoring rules for all seasons in one query
|
|
|
|
|
const seasonRows = await db.query.seasons.findMany({
|
|
|
|
|
where: inArray(schema.seasons.id, seasonIds),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
|
|
|
|
|
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
|
|
|
|
|
pointsFor7th: true, pointsFor8th: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const rulesBySeasonId = new Map<string, ScoringRules>(
|
|
|
|
|
seasonRows.map((s) => [s.id, {
|
|
|
|
|
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
|
|
|
|
|
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
|
|
|
|
|
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
|
|
|
|
|
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
|
|
|
|
|
}])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const seasonId of seasonIds) {
|
|
|
|
|
const teamId = teamBySeasonId.get(seasonId);
|
|
|
|
|
if (!teamId) continue;
|
|
|
|
|
|
|
|
|
|
const rules = rulesBySeasonId.get(seasonId);
|
|
|
|
|
if (!rules) continue;
|
|
|
|
|
|
|
|
|
|
const delta =
|
|
|
|
|
calculateBracketPoints(params.newFloor, rules, params.bracketTemplateId) -
|
|
|
|
|
calculateBracketPoints(params.oldFloor, rules, params.bracketTemplateId);
|
|
|
|
|
if (delta <= 0) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await recordTeamScoreEvent(
|
|
|
|
|
{
|
|
|
|
|
teamId,
|
|
|
|
|
seasonId,
|
|
|
|
|
scoringEventId: params.eventId,
|
|
|
|
|
scoringEventName: params.eventName,
|
|
|
|
|
sportName,
|
|
|
|
|
participantIds: [params.participantId],
|
|
|
|
|
pointsDelta: delta,
|
|
|
|
|
matchId: params.matchId,
|
|
|
|
|
},
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`[TeamScoreEvents] Failed to record match score event for team ${teamId} match ${params.matchId}:`,
|
|
|
|
|
err
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
/**
|
|
|
|
|
* Ledger label for a one-shot finalization, derived from the scoring pattern so
|
|
|
|
|
* every caller agrees. Deriving it here rather than requiring a parameter is
|
|
|
|
|
* deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller
|
|
|
|
|
* that simply forgets to pass one would otherwise stamp rows with the anchor
|
|
|
|
|
* event's own name ("The Open") instead of "Final Standings".
|
|
|
|
|
*/
|
|
|
|
|
function defaultEventName(scoringPattern: string | null | undefined): string {
|
|
|
|
|
return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings";
|
|
|
|
|
}
|
|
|
|
|
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
/**
|
|
|
|
|
* Records ledger rows for a sports season whose final placements have just been
|
|
|
|
|
* assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1).
|
|
|
|
|
*
|
|
|
|
|
* These patterns award all of their points in one step at finalization, so they
|
|
|
|
|
* have no per-match deltas and were never written to team_score_events at all:
|
|
|
|
|
* recordMatchScoreEvents is bracket-only and fires from match processing. The
|
|
|
|
|
* effect was that golf results silently never appeared in Recent Scores.
|
|
|
|
|
*
|
|
|
|
|
* One row per team (matching the event-level unique index), carrying that team's
|
|
|
|
|
* summed award and every participant that contributed. Points come from
|
|
|
|
|
* calculatePickPoints, so a tied golfer contributes the same split award the
|
|
|
|
|
* standings show.
|
|
|
|
|
*
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
* Every row is anchored to a real scoring event, because the event-level unique
|
|
|
|
|
* index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as
|
|
|
|
|
* distinct — a null anchor would defeat the upsert entirely. If no anchor can be
|
|
|
|
|
* found the ledger write is skipped rather than risking duplicates; standings are
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
* unaffected either way.
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
*
|
|
|
|
|
* Re-running is safe even if the anchor moves (a later event completes, or the
|
|
|
|
|
* backfill runs against changed data). Stale rows for this sports season are
|
|
|
|
|
* cleared before writing, so the upsert alone is not load-bearing.
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
*/
|
|
|
|
|
export async function recordFinalPlacementScoreEvents(
|
|
|
|
|
params: {
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
eventId?: string | null;
|
|
|
|
|
eventName?: string | null;
|
|
|
|
|
},
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
|
|
|
|
where: eq(schema.sportsSeasons.id, params.sportsSeasonId),
|
|
|
|
|
columns: { id: true, scoringPattern: true },
|
|
|
|
|
with: { sport: { columns: { name: true } } },
|
|
|
|
|
});
|
|
|
|
|
if (!sportsSeason) return;
|
|
|
|
|
|
|
|
|
|
// Resolve the anchor event: the caller's, else the most recently completed
|
|
|
|
|
// event for this sports season.
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
//
|
|
|
|
|
// The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`,
|
|
|
|
|
// and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone
|
|
|
|
|
// would rank a never-completed event (common for sibling major windows) above
|
|
|
|
|
// every finished one. Restrict to completed events and order explicitly.
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
let eventId = params.eventId ?? null;
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern);
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
if (!eventId) {
|
|
|
|
|
const anchor = await db.query.scoringEvents.findFirst({
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
where: and(
|
|
|
|
|
eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
|
|
|
|
|
eq(schema.scoringEvents.isComplete, true)
|
|
|
|
|
),
|
|
|
|
|
columns: { id: true },
|
|
|
|
|
orderBy: [
|
|
|
|
|
sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`,
|
|
|
|
|
sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`,
|
|
|
|
|
desc(schema.scoringEvents.id),
|
|
|
|
|
],
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
});
|
|
|
|
|
if (!anchor) {
|
|
|
|
|
logger.warn(
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
`[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write`
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
eventId = anchor.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scoring placements for this sports season, plus the tie spans they imply.
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
// Counted from these same rows rather than re-querying them.
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
const results = await db.query.seasonParticipantResults.findMany({
|
|
|
|
|
where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId),
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
columns: { sportsSeasonId: true, participantId: true, finalPosition: true },
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
});
|
|
|
|
|
const positionByParticipantId = new Map<string, number>();
|
|
|
|
|
for (const row of results) {
|
|
|
|
|
if (row.finalPosition !== null && row.finalPosition > 0) {
|
|
|
|
|
positionByParticipantId.set(row.participantId, row.finalPosition);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (positionByParticipantId.size === 0) return;
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
const sharedPlacementCounts = countSharedPlacements(results);
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
|
|
|
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
|
|
|
where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId),
|
|
|
|
|
columns: { seasonId: true },
|
|
|
|
|
});
|
|
|
|
|
if (seasonSports.length === 0) return;
|
|
|
|
|
const seasonIds = seasonSports.map((ss) => ss.seasonId);
|
|
|
|
|
|
|
|
|
|
const picks = await db.query.draftPicks.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
inArray(schema.draftPicks.seasonId, seasonIds),
|
|
|
|
|
inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()])
|
|
|
|
|
),
|
|
|
|
|
columns: { teamId: true, seasonId: true, participantId: true },
|
|
|
|
|
});
|
|
|
|
|
if (picks.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const seasonRows = await db.query.seasons.findMany({
|
|
|
|
|
where: inArray(schema.seasons.id, seasonIds),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true,
|
|
|
|
|
pointsFor4th: true, pointsFor5th: true, pointsFor6th: true,
|
|
|
|
|
pointsFor7th: true, pointsFor8th: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const rulesBySeasonId = new Map<string, ScoringRules>(
|
|
|
|
|
seasonRows.map((s) => [s.id, {
|
|
|
|
|
pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd,
|
|
|
|
|
pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th,
|
|
|
|
|
pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th,
|
|
|
|
|
pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th,
|
|
|
|
|
}])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Accumulate per (season, team) — the ledger's event-level grain.
|
|
|
|
|
const byTeam = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ teamId: string; seasonId: string; points: number; participantIds: string[] }
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
for (const pick of picks) {
|
|
|
|
|
const rules = rulesBySeasonId.get(pick.seasonId);
|
|
|
|
|
const finalPosition = positionByParticipantId.get(pick.participantId);
|
|
|
|
|
if (!rules || finalPosition === undefined) continue;
|
|
|
|
|
|
|
|
|
|
const points = calculatePickPoints(
|
|
|
|
|
finalPosition,
|
|
|
|
|
sportsSeason.scoringPattern,
|
|
|
|
|
rules,
|
|
|
|
|
{
|
|
|
|
|
tiedParticipants: lookupSharedPlacementCount(
|
|
|
|
|
sharedPlacementCounts,
|
|
|
|
|
params.sportsSeasonId,
|
|
|
|
|
finalPosition
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
if (points <= 0) continue;
|
|
|
|
|
|
|
|
|
|
const key = `${pick.seasonId}:${pick.teamId}`;
|
|
|
|
|
const entry = byTeam.get(key) ?? {
|
|
|
|
|
teamId: pick.teamId,
|
|
|
|
|
seasonId: pick.seasonId,
|
|
|
|
|
points: 0,
|
|
|
|
|
participantIds: [],
|
|
|
|
|
};
|
|
|
|
|
entry.points += points;
|
|
|
|
|
entry.participantIds.push(pick.participantId);
|
|
|
|
|
byTeam.set(key, entry);
|
|
|
|
|
}
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
// Clear this sports season's existing event-level rows before writing, so a
|
|
|
|
|
// re-run whose anchor landed on a different event replaces rather than adds.
|
|
|
|
|
// Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns
|
|
|
|
|
// write no other event-level rows (QP majors award qualifying points, not
|
|
|
|
|
// fantasy points), and qualifying-bracket matches carry a non-null matchId.
|
|
|
|
|
const eventIds = await db.query.scoringEvents.findMany({
|
|
|
|
|
where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId),
|
|
|
|
|
columns: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (eventIds.length > 0) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(schema.teamScoreEvents)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
isNull(schema.teamScoreEvents.matchId),
|
|
|
|
|
inArray(
|
|
|
|
|
schema.teamScoreEvents.scoringEventId,
|
|
|
|
|
eventIds.map((e) => e.id)
|
|
|
|
|
),
|
|
|
|
|
inArray(schema.teamScoreEvents.seasonId, seasonIds)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Record final-placement points in the score-events ledger
qualifying_points (golf, tennis, CS2) and season_standings (F1) award all
of their points in one step at finalization, so they produce no per-match
deltas. recordMatchScoreEvents is bracket-only and fires from match
processing, which meant these sports were never written to
team_score_events at all and silently never appeared in Recent Scores.
Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints
and processSeasonStandings. It writes one row per team at the ledger's
event-level grain, carrying that team's summed award and every
contributing participant, with points from calculatePickPoints so a tied
golfer contributes the same split award the standings show.
The row is anchored to a real scoring event rather than a null one: the
event-level unique index is (teamId, seasonId, scoringEventId) and
Postgres treats NULLs as distinct, so a null anchor would duplicate rows
on every re-finalization instead of upserting. When no anchor can be
resolved the ledger write is skipped, which leaves standings unaffected.
Adds regression coverage for the two screens that had diverged —
getTeamScoreBreakdown and computeCoronaStates — including an assertion
that the team page's actualPoints equals calculateTeamScore's totalPoints
for the same roster, and cases proving undrafted participants still count
toward a tie span.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
|
|
|
for (const entry of byTeam.values()) {
|
|
|
|
|
try {
|
|
|
|
|
await recordTeamScoreEvent(
|
|
|
|
|
{
|
|
|
|
|
teamId: entry.teamId,
|
|
|
|
|
seasonId: entry.seasonId,
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
scoringEventName: eventName,
|
|
|
|
|
sportName: sportsSeason.sport?.name ?? null,
|
|
|
|
|
participantIds: entry.participantIds,
|
|
|
|
|
pointsDelta: entry.points,
|
|
|
|
|
},
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`,
|
|
|
|
|
err
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
export interface TeamScoreEventEntry {
|
|
|
|
|
id: string;
|
|
|
|
|
teamId: string;
|
|
|
|
|
teamName: string;
|
|
|
|
|
scoringEventId: string | null;
|
|
|
|
|
scoringEventName: string | null;
|
|
|
|
|
sportName: string | null;
|
|
|
|
|
pointsDelta: string;
|
|
|
|
|
occurredAt: Date;
|
|
|
|
|
participants: Array<{ id: string; name: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the most recent scoring events for a league season, ordered by
|
|
|
|
|
* occurredAt DESC. Participant names are fetched from the stored participantIds.
|
|
|
|
|
*/
|
|
|
|
|
export async function getRecentTeamScoreEvents(
|
|
|
|
|
seasonId: string,
|
|
|
|
|
limit = 10,
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
): Promise<TeamScoreEventEntry[]> {
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
const rows = await db.query.teamScoreEvents.findMany({
|
|
|
|
|
where: eq(schema.teamScoreEvents.seasonId, seasonId),
|
|
|
|
|
with: {
|
|
|
|
|
team: { columns: { id: true, name: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [desc(schema.teamScoreEvents.occurredAt)],
|
|
|
|
|
limit,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
// Batch-fetch participant names for all stored participant IDs
|
|
|
|
|
const allParticipantIds = [
|
|
|
|
|
...new Set(rows.flatMap((r) => r.participantIds ?? [])),
|
|
|
|
|
];
|
2026-06-16 22:11:01 +00:00
|
|
|
const participantNameById = await findParticipantNamesByIds(db, allParticipantIds);
|
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
|
|
|
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
|
id: row.id,
|
|
|
|
|
teamId: row.teamId,
|
|
|
|
|
teamName: row.team.name,
|
|
|
|
|
scoringEventId: row.scoringEventId,
|
|
|
|
|
scoringEventName: row.scoringEventName,
|
|
|
|
|
sportName: row.sportName,
|
|
|
|
|
pointsDelta: row.pointsDelta,
|
|
|
|
|
occurredAt: row.occurredAt,
|
|
|
|
|
participants: (row.participantIds ?? [])
|
|
|
|
|
.map((id) => {
|
|
|
|
|
const name = participantNameById.get(id);
|
|
|
|
|
return name ? { id, name } : null;
|
|
|
|
|
})
|
|
|
|
|
.filter((p): p is { id: string; name: string } => p !== null),
|
|
|
|
|
}));
|
|
|
|
|
}
|