From 01e2f046562cda970202e8072a2d641c5adf444a Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 4 May 2026 09:33:13 -0700 Subject: [PATCH] Docs update --- AGENTS.md | 12 ++ CLAUDE.md | 12 ++ docs/agents/brackt-sport.md | 398 ++++++++++++++++++++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 docs/agents/brackt-sport.md diff --git a/AGENTS.md b/AGENTS.md index bf239ad..43e94c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,18 @@ npm run test:e2e:headless # Cypress E2E npm run test:all # everything ``` +## Code Search + +**Always use `grepai` instead of `grep`** for searching code. `grepai` is the preferred search tool for this project. + +It uses natural language semantic search, not exact text matching: + +```bash +grepai search "where draft picks are validated" +grepai search "socket.io event handlers" --limit 5 +grepai search "user authentication middleware" --path app/middleware +``` + ## Critical Rules **Routes**: Every new route file must also be registered in `app/routes.ts` — React Router 7 does not auto-discover files. Forgetting this causes a silent 404. See [routing guide](docs/agents/routing.md). diff --git a/CLAUDE.md b/CLAUDE.md index 918dca8..fddc876 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,18 @@ npm run test:e2e:headless # Cypress E2E npm run test:all # everything ``` +## Code Search + +**Always use `grepai` instead of `grep`** for searching code. `grepai` is the preferred search tool for this project. + +It uses natural language semantic search, not exact text matching: + +```bash +grepai search "where draft picks are validated" +grepai search "socket.io event handlers" --limit 5 +grepai search "user authentication middleware" --path app/middleware +``` + ## Critical Rules **Routes**: Every new route file must also be registered in `app/routes.ts` — React Router 7 does not auto-discover files. Forgetting this causes a silent 404. See [routing guide](docs/agents/routing.md). diff --git a/docs/agents/brackt-sport.md b/docs/agents/brackt-sport.md new file mode 100644 index 0000000..c7949c3 --- /dev/null +++ b/docs/agents/brackt-sport.md @@ -0,0 +1,398 @@ +# Brackt as a Draftable Sport — Design Reference + +## What It Is + +"Brackt" is an optional meta-sport commissioners can add to any league. Each team drafts exactly one **manager** from their own league. At season end, that manager's final overall fantasy ranking (1st–8th across all other sports) determines how many points their drafter earns. Resolution is commissioner-triggered once all other sports finalize. Self-drafting is allowed and valid strategy. + +--- + +## Schema + +### `sportsSeasons` — new column + +```typescript +fantasySeasonId: uuid("fantasy_season_id").references(() => seasons.id) +``` + +When set, this sports season was auto-generated for a specific fantasy season. It is **league-private** — never shared across leagues, never shown in the sports picker or the league creation wizard. + +### `simulatorTypeEnum` + +Add `"brackt"` to the existing enum. + +### `auditActionEnum` + +Add `"brackt_resolved"` to the existing enum. + +**Migration**: `npm run db:generate && npm run db:migrate`. The new column defaults to `null` — backward-compatible. + +--- + +## Admin One-Time Setup + +An admin creates a single **global Brackt sport** and a **template sports season** via the existing admin UI. These are created once and reused across all leagues. + +**Sport** (`/admin/sports/new`): +- Name: `Brackt`, Slug: `brackt`, SimulatorType: `brackt` + +**Template sports season** (`/admin/sports-seasons/new`): +- Sport: Brackt, Year: 2026, Status: `upcoming` +- `draftOn`: `1970-01-01`, `draftOff`: `2099-12-31` — never expires +- `fantasySeasonId`: null — this is the template, not a league copy +- No participants — the template is just a selectable placeholder + +The template appears in the league creation wizard's sport picker. Per-league copies (see below) are excluded from it via `isNull(fantasySeasonId)`. + +--- + +## Data Architecture + +### Per-League Brackt Season + +When a league includes Brackt, `createBracktSportsSeasonForLeague()` runs during league creation: + +1. Inserts a new `sportsSeasons` row with `fantasySeasonId = season.id` +2. Replaces the template's `seasonSports` link with this new per-league copy +3. Bulk-inserts `N` `seasonParticipants` rows — one per team: + - `name`: team name + - `externalId`: team ID ← **critical**: used for robust team→participant matching at resolution; immune to name changes + - `expectedValue`: `"0"`, `vorpValue`: `"0"` (will be updated by Harville immediately after) +4. Calls `runBracktHarvilleForFantasySeason()` immediately so initial EVs are uniform (~27.5 for 8 teams) rather than 0 + +**Use `db.insert()` directly** — do not use `createParticipant()`, which auto-creates canonical participant rows. Brackt participants are manager-participants with no canonical counterpart. + +### `seasonParticipants.vorpValue` is the source of truth for autopick + +`getTopAvailableParticipant()` sorts all undrafted participants across all eligible sports by `vorpValue` DESC. Brackt participants must have correct, up-to-date `vorpValue` so they rank appropriately relative to other sport participants. This is achieved by running the Harville simulation after every pick. + +--- + +## EV Pipeline — Critical Architecture Rule + +> **All EV calculations must use a single `db` instance threaded through the entire call chain. Never call `database()` inside model functions that are reachable from the timer context.** + +### Why + +`database()` uses `AsyncLocalStorage`. It is scoped to HTTP request handlers. The draft timer (`server/timer.ts`) fires outside any HTTP request — calling `database()` there throws `"DatabaseContext not set"`. This breaks the EV pipeline in the timer-triggered autopick path. + +### The Rule + +Every function in the EV pipeline must accept a `db` parameter and pass it through: + +``` +runBracktHarvilleForFantasySeason(seasonId, db) + → BracktSimulator.simulate(bracktSportsSeasonId, db) + → batchUpsertParticipantEVs(inputs, db) + → syncVorpForSeason(sportsSeasonId, db) +``` + +No function in this chain may call `database()` directly. They receive `db` from the caller. + +The timer passes `db` from `server/db.ts` (the global singleton). HTTP request handlers pass `db` from `database()`. Both are valid Drizzle instances connected to the same Postgres database. + +### Where to Run the Harville + +Run `runBracktHarvilleForFantasySeason(seasonId, db)` in **three places**: + +1. **`executeAutoPick`** — awaited, before participant selection. This ensures autopick sees current VORPs. +2. **`make-pick.ts` (manual pick handler)** — fire-and-forget after the pick commits. Updates EVs for all clients after a human picks. +3. **`createBracktSportsSeasonForLeague`** — awaited at league creation. Seeds uniform initial EVs. + +After running Harville, emit the updated EVs to all draft room clients via socket: + +```typescript +getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { + evs: result.evs, // { participantId, expectedValue }[] +}); +``` + +--- + +## BracktSimulator Algorithm + +**File**: `app/services/simulations/brackt-simulator.ts` + +``` +simulate(bracktSportsSeasonId, db): + 1. Look up bracktSportsSeason → get fantasySeasonId + 2. Get all seasonSports for this fantasy season; separate brackt vs non-brackt + 3. Get all bracktParticipants (externalId = teamId) + 4. Get all non-brackt draftPicks for this fantasy season with their participant.expectedValue + 5. For each non-brackt sport, compute averageAvailableEV: + - mean expectedValue of undrafted participants + - fallback 0 if no EVs set + 6. For each team: + projectedTotal = sum(EV of drafted non-brackt picks) + + sum(averageAvailableEV for each non-brackt sport not yet picked) + 7. weights = max(0, projectedTotal) for each team + 8. placementMatrix = computeHarville(weights, 8) + 9. Map back to bracktParticipants → SimulationResult[] +``` + +**Edge case**: All weights = 0 (pre-draft) → Harville produces uniform 1/N per placement. This is correct — uniform EVs → uniform VORPs → Brackt picks are not disproportionately valued early in the draft. + +**Registration**: Add `"brackt"` to `SIMULATOR_TYPES` in `app/services/simulations/registry.ts` and map it to `BracktSimulator`. + +--- + +## `runBracktHarvilleForFantasySeason` + +**File**: `app/lib/brackt-ev-updater.server.ts` + +```typescript +export type BracktEvUpdate = { + participantId: string; + expectedValue: number; + vorpValue: number; +}; + +export async function runBracktHarvilleForFantasySeason( + fantasySeasonId: string, + db: DbInstance // required — never optional +): Promise<{ updated: false } | { updated: true; evs: BracktEvUpdate[] }> +``` + +Steps: +1. Find the Brackt sports season via `sportsSeasons WHERE fantasySeasonId = ? AND sport.slug = 'brackt'` +2. Call `simulator.simulate(bracktSportsSeasonId, db)` +3. If results empty, return `{ updated: false }` +4. Call `batchUpsertParticipantEVs(inputs, db)` — writes to `seasonParticipantExpectedValues` and triggers `syncVorpForSeason(db)` which writes to `seasonParticipants.vorpValue` +5. Re-read the updated `vorpValue` for all Brackt participants from DB (one query on `seasonParticipants` filtered by `sportsSeasonId`) +6. Compute `evs[]`: `expectedValue` from probabilities × scoring tiers; `vorpValue` from the DB read in step 5 +7. Return `{ updated: true, evs }` + +Reading `vorpValue` from DB after `syncVorpForSeason` ensures the value is exactly what the server stored — no approximation, no client-side re-derivation. + +--- + +## Client-Side Display (`$leagueId.draft.$seasonId.tsx`) + +The client never computes VORP. The server is the sole source of truth — `syncVorpForSeason` writes `vorpValue` to `seasonParticipants` after every Harville run, and the socket pushes those values to all clients immediately. + +### Socket Payload + +`brackt-evs-updated` must include both `expectedValue` and `vorpValue` so clients can sort correctly in real time without waiting for a revalidation: + +```typescript +// Server emits (after runBracktHarvilleForFantasySeason): +"brackt-evs-updated": (data: { + evs: { participantId: string; expectedValue: number; vorpValue: number }[] +}) => void; +``` + +`runBracktHarvilleForFantasySeason` should read the updated `vorpValue` back from the DB after `batchUpsertParticipantEVs` completes (since `syncVorpForSeason` writes it) and include it in the returned `evs`. + +### Initial Load + +Seed `serverBracktVorps` from loader data: + +```typescript +const [serverBracktVorps, setServerBracktVorps] = useState>( + () => new Map( + availableParticipants + .filter((p) => p.sportsSeasonId === bracktSportsSeasonId) + .map((p) => [p.id, parseFloat(p.vorpValue ?? "0")]) + ) +); +``` + +### Socket Updates + +Listen for `brackt-evs-updated` and patch the VORP map: + +```typescript +useEffect(() => { + const handler = (data: { evs: { participantId: string; vorpValue: number }[] }) => { + setServerBracktVorps((prev) => { + const next = new Map(prev); + for (const { participantId, vorpValue } of data.evs) { + next.set(participantId, vorpValue); + } + return next; + }); + }; + on("brackt-evs-updated", handler); + return () => off("brackt-evs-updated", handler); +}, [on, off]); +``` + +### Sorting + +All participants sort by `vorpValue` — server-computed for every sport including Brackt: + +```typescript +const sortedAvailableParticipants = useMemo(() => { + const effectiveVorp = (p) => { + if (p.sportsSeasonId === bracktSportsSeasonId) { + return serverBracktVorps.get(p.id) ?? parseFloat(p.vorpValue ?? "0"); + } + return parseFloat(p.vorpValue ?? "0"); + }; + return availableParticipants.toSorted((a, b) => { + const diff = effectiveVorp(b) - effectiveVorp(a); + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }); +}, [availableParticipants, bracktSportsSeasonId, serverBracktVorps]); +``` + +`serverBracktVorps` overrides stale loader values whenever the server pushes an update. When no socket update has arrived yet, the loader's `vorpValue` is used as the fallback — both reflect the same server-computed value. + +--- + +## League Creation Flow (`app/routes/leagues/new.tsx`) + +### Sports Selection Step + +Brackt appears in the picker like any other sport. When selected, show a callout: + +> "Each team drafts one manager from your league. At season end, their total fantasy points (across all other sports) earns you placement points — 1st through 8th. You can draft yourself!" + +### Action + +After `createSeason()` + `createManyTeams()`: + +```typescript +const bracktTemplateId = await findBracktTemplateIdInSelection(selectedSportsSeasonIds, bracktSport.id); +if (bracktTemplateId) { + // Remove template from the regular sport linking; brackt-setup handles it + const otherSportsSeasonIds = selectedSportsSeasonIds.filter(id => id !== bracktTemplateId); + await linkMultipleSportsToSeason(season.id, otherSportsSeasonIds); + await createBracktSportsSeasonForLeague(bracktTemplateId, season.id, leagueName, year, createdTeams); +} else { + await linkMultipleSportsToSeason(season.id, selectedSportsSeasonIds); +} +``` + +### `findDraftableSportsSeasons()` filter + +In `app/models/sports-season.ts`, add `isNull(ss.fantasySeasonId)` to exclude per-league Brackt copies from appearing in the picker. + +--- + +## League Settings — Team Changes (`$leagueId.settings.tsx`) + +Whenever teams are added or removed, call: + +```typescript +await syncBracktParticipants(season.id); +``` + +`syncBracktParticipants` guards against deleting participants that have already been drafted. If a team is removed after the draft has started, only undrafted Brackt participants are deleted; drafted ones remain. + +--- + +## Draft Room Considerations + +- **Self-drafting**: Explicitly allowed. No restriction needed. +- **Eligibility**: `calculateDraftEligibility()` already enforces one pick per sport per team. Brackt is treated as any other sport — one required slot. No changes needed. +- **Display**: Brackt participants show with the Brackt sport icon and manager name. The existing participant card handles this without changes. +- **Draft queue**: Auto-pick respects the queue. If a team queues a Brackt participant, it will be picked from the queue like any other queued player. The VORP-based fallback applies only when the queue is empty. + +--- + +## Autopick Ordering + +`getTopAvailableParticipant()` sorts all undrafted participants from all eligible sports by `vorpValue DESC`. For this to work correctly with Brackt: + +- `vorpValue` on Brackt `seasonParticipants` must be populated and current at pick time +- `runBracktHarvilleForFantasySeason()` must be awaited inside `executeAutoPick()` **before** calling `autoPickForTeam()` +- The `db` instance passed to `runBracktHarvilleForFantasySeason()` must be the same instance used by all downstream model functions (`batchUpsertParticipantEVs`, `syncVorpForSeason`) + +In the timer path, `db` is `server/db.ts`'s singleton. In the HTTP path, `db` is `database()`. Both are fine as long as they're threaded through — not bypassed with a fresh `database()` call inside model functions. + +--- + +## Season Resolution + +### Readiness Check — `isBracktResolvable(seasonId)` + +Returns `{ ready: boolean; pendingSports: string[] }`. + +A sport is "pending" if: +- Its `sportsSeasons.status !== "completed"`, OR +- Any of its participants has `seasonParticipantResults.isPartialScore = true` + +### Commissioner UI — `BracktResolutionCard` + +Show on the standings page when: +- `isBracktIncluded(seasonId)` is true +- Brackt `sportsSeasons.status !== "completed"` + +Shows current projected rankings (from last Harville run). If ready → "Finalize Brackt" button. If not → "Waiting for: [NBA Playoffs, ...]". + +### Resolution — `resolveBrackt(seasonId, actorUserId)` + +1. Read `teamStandings.totalPoints` for all teams (this is purely non-Brackt points since Brackt isn't scored yet) +2. Sort by `totalPoints DESC`; tiebreak: 1st-place counts → 2nd → etc. +3. For each Brackt participant (matched via `externalId = teamId`): upsert `seasonParticipantResults` with `finalPosition = rank, isPartialScore = false` +4. Mark Brackt `sportsSeasons.status = "completed"` +5. Call `recalculateAffectedLeagues(bracktSportsSeasonId)` — applies Brackt placement points to team totals +6. Log to `commissionerAuditLog` with action `"brackt_resolved"` + +**No circular dependency**: resolution reads `totalPoints` before Brackt is scored, so Brackt points don't feed into their own resolution. + +--- + +## Admin Sports-Seasons Listing + +Add a filter toggle to hide rows where `fantasySeasonId IS NOT NULL`. Per-league Brackt seasons are internal and should not clutter the admin listing by default. + +--- + +## Rules & How to Play + +Both files are hardcoded React components. + +**`app/routes/rules.tsx`** — New "Brackt — Draft a Manager" section: +- Draft exactly one manager from your league (self-drafting allowed) +- Points based on final overall ranking across all other sports (1st = 100 pts … 8th = 15 pts) +- Commissioner triggers Brackt resolution after all other sports conclude +- One required Brackt round is added to every league that includes it + +**`app/routes/how-to-play.tsx`** — New "The Meta Game: Brackt" section: +- Strategic angle: pick a manager who will perform well overall +- Draft timing matters — strong early drafters show higher projected EV +- Revealed at season end after all other sports finalize +- You are a valid pick + +--- + +## Gotchas + +| Risk | Mitigation | +|---|---| +| `database()` unavailable in timer context | Pass `db` through the entire EV pipeline; never call `database()` inside model functions | +| `createParticipant()` auto-creates canonical participant rows | Use `db.insert(schema.seasonParticipants)` directly with `participantId: null` | +| Per-league Brackt seasons leak into sports picker | `isNull(fantasySeasonId)` filter in `findDraftableSportsSeasons()` | +| Admin listings cluttered with per-league Brackt seasons | Filter toggle in `/admin/sports-seasons` | +| `BracktSimulator` reads stale EVs (all-zero pre-draft) | Harville produces uniform distribution from zero weights — valid behavior | +| Brackt VORP starts at 0 before any Harville run | Call `runBracktHarvilleForFantasySeason()` immediately after `createBracktSportsSeasonForLeague()` | +| `syncVorpForSeason` uses replacement level for 12-14 players; Brackt has 8 | `calculateReplacementLevel` clamps to `Math.min(idx, length - 1)`, so it uses position 8 (last) for all three. VORP = EV − minEV. This is correct. | +| Resolution uses `totalPoints` which could include prior Brackt points (if re-running) | Guard: if Brackt is already resolved, require explicit override; otherwise this is correct since Brackt points are 0 until resolution | +| Team removed after draft — Brackt participant already drafted | `syncBracktParticipants` checks `pickedIds` and only removes undrafted participants | +| Participant-to-team matching at resolution breaks if team renamed | Store `team.id` in `externalId` at creation; match by `externalId` at resolution | +| `recalculateAffectedLeagues` after resolution fires Discord notification | Acceptable — it announces Brackt final scoring | +| `isSeasonComplete()` returns false until Brackt resolved | Intentional — commissioner finalizing Brackt is the explicit season-close action | + +--- + +## File Map + +| File | Purpose | +|---|---| +| `database/schema.ts` | `fantasySeasonId` on `sportsSeasons`, enum additions | +| `app/lib/brackt-constants.ts` | `BRACKT_SPORT_SLUG = "brackt"` | +| `app/lib/brackt-setup.server.ts` | All Brackt lifecycle model functions | +| `app/lib/brackt-ev-updater.server.ts` | `runBracktHarvilleForFantasySeason()` — EV pipeline entry point | +| `app/services/simulations/brackt-simulator.ts` | `BracktSimulator` — Harville projection | +| `app/services/simulations/registry.ts` | Register `"brackt"` simulator type | +| `app/models/sports-season.ts` | `isNull(fantasySeasonId)` filter in `findDraftableSportsSeasons()` | +| `app/models/draft-utils.ts` | `runBracktHarvilleForFantasySeason()` in `executeAutoPick()`; Harville + socket emit in autodraft chain | +| `app/routes/api/draft.make-pick.ts` | Fire-and-forget Harville + `brackt-evs-updated` emit after manual pick | +| `server/socket.ts` | `brackt-evs-updated` in `ServerToClientEvents` | +| `app/routes/leagues/new.tsx` | Auto-create per-league Brackt season; callout in sport picker | +| `app/routes/leagues/$leagueId.settings.tsx` | `syncBracktParticipants()` on team count changes | +| `app/routes/leagues/$leagueId.standings.$seasonId.tsx` | `BracktResolutionCard` for commissioner | +| `app/routes/rules.tsx` | Brackt rules section | +| `app/routes/how-to-play.tsx` | Brackt how-to section | +| `app/routes/admin/sports-seasons.tsx` | Filter toggle for per-league copies |