Merge the wiring & data train (6 waves) into main

Real entity assets (headshots/logos/book wordmarks), record-by-grade-tier,
outlook mode, market-breadth, Parlay Lab, grade-shift timeline, /u house-mode
public record, pitcher arsenal (Savant), combat intelligence v1 (MMA), and
three trust-bug fixes. 253 suites / 3069 tests green, next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 17:42:20 -04:00
100 changed files with 9223 additions and 515 deletions
+12
View File
@@ -242,6 +242,18 @@ Published only. Unknown AND unpublished return the SAME 404 body
} }
``` ```
### HOUSE profile (Wave 5A, D2)
The reserved handle `HOUSE_HANDLE` (env, default `vyndr`) resolves to the
PUBLIC model record (`getModelAggregate()` with NO userId → the `user_id=NULL`
ledger rows) WITHOUT a `public_profiles` row, and is ALWAYS public (the
partner-pitch weapon). Same response shape + two extra fields:
`{ ..., house: true, label: 'VYNDR MODEL · PUBLIC RECORD' }`. Entries are the
public settled rows (`user_id IS NULL`, misses included). Reserved before the
publish lookup — a user who claims it is shadowed. Every OTHER handle keeps the
private-by-default, byte-identical-404 contract. It never 404s (a fetch failure
degrades to an honest empty/building state). Portrait share crop: route handler
`GET /u/[handle]/portrait` → 1080×1350 PNG (real aggregate or tagline fallback).
### `GET|POST /api/profiles/me` (requireAuth) ### `GET|POST /api/profiles/me` (requireAuth)
GET → `{ profile: { handle, published, created_at } | null }`. GET → `{ profile: { handle, published, created_at } | null }`.
POST `{ handle, published }` → upsert own row (service role). Handle must POST `{ handle, published }` → upsert own row (service role). Handle must
+11 -1
View File
@@ -1,5 +1,15 @@
# VYNDR — STATE OF THE WORLD # VYNDR — STATE OF THE WORLD
### As of `e9c0a59` (main, 2026-07-13). This file opens every future session. Update it when a train ships. ### As of `e9c0a59` (main) + `wiring/data-train` READY, 2026-07-13. This file opens every future session. Update it when a train ships.
## READY TO MERGE — WIRING & DATA TRAIN (branch `wiring/data-train`, NOT on main yet)
Governed by `specs/wiring-data-train.md` (the Step-0 MAP + build plans) + `specs/combat-intelligence.md` + the global visual reference `specs/design-reference/vyndr-system.html` (build toward it; live wordmark kept). **All 6 waves DONE, merged onto `wiring/data-train`, green (253 suites / 3069 tests) + `next build` exit 0. Awaiting the founder's word before main.** Real assets verified live (MLB/ESPN headshot CDNs, ESPN-MMA feed, Baseball Savant CSV).
- **Wave 1 — trust bugs:** billing renewal honest render (`billingDisplay.classifyRenewal` — no far-future placeholder); James Wood nameKey-collision fixed (`mlbStatsAdapter` teamHint disambiguation + streaks join-invariant); DeskShowcase "$1M terminal" → deadpan copy.
- **Wave 2 — sport-agnostic entity layer:** real player headshots threaded from ingestion (MLB MLBAM + NBA/WNBA ESPN athlete ids that were fetched-and-discarded) across slate/scan/hotlist/search/grade card; soccer = honest monogram (no free id); 8 self-authored SVG book wordmarks (`web/public/books/*.svg`, swappable for official art) + all 10 book keys resolve; team-logo abbr aliases. Storage: id on the `enriched` grade at `snapshotService` (zero new I/O).
- **Wave 3 — record by grade tier (Addition 2):** ONE shared `TierRecord` (`lib/tierRecord.js` + component) on dashboard + /u + ledger; per-tier W-L always, hit-% only at n≥20 per tier (gate stays in `getModelAggregate`).
- **Wave 4 — missing surfaces:** Outlook mode (grid never blank → yesterday receipts / tomorrow schedule); Market-Breadth median-consensus-vs-model strip (self-hides <2 books); Parlay Lab `/parlay` (slate-independent leg source); live Grade-Shift timeline (`GradeShift`).
- **Wave 5 — /u house-mode + arsenal:** house handle `vyndr` surfaces the real `user_id=NULL` public model record + per-tier calibration + 1080×1350 portrait/OG (user-handle privacy 404s stay byte-identical); Baseball Savant pitcher-arsenal (`savantAdapter`, free CSV, verified) → `PitcherArsenal` card, self-hides on absent.
- **Wave 6 — combat v1 (MMA):** ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes (sport-scoped `COMBAT_ARCHETYPES` — FINISHER color collided w/ soccer + tripped the ΔE gate, so kept separate) + odds-api ML/round-totals + style-edge verdict. NOT in the snapshot/settle loop; method/round/props + fighter photos + matchup-GRADE engine + ufcstats scraping all DEFERRED, flagged data-limited in-UI. New routes: `/fight/[id]`, `/parlay`, `/u/[handle]/portrait`.
POST-MERGE TODO: NBA/WNBA headshot coverage + combat depth need prod runtime verification; soccer headshots blocked on `API_FOOTBALL_KEY`; combat settlement + matchup-grade engine are the next combat sub-wave.
## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live) ## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live)
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
+55
View File
@@ -0,0 +1,55 @@
# VYNDR — COMBAT INTELLIGENCE LAYER (spec)
### Wave 6 of the wiring/data train. Net-new sport (MMA/UFC). Governs the combat build. Honest free v1; the full matchup-GRADE engine is a DEFERRED sub-wave. Build toward `specs/design-reference/vyndr-system.html` (FIGHTER A / VERDICT / FIGHTER B, GRAPPLER%/STRIKER% blend, tale-of-the-tape, MONEYLINE/DECISION/SUBMISSION/KO grades). Keep the live wordmark.
## DATA SEMANTICS (unchanged, restated for combat)
VYNDR never generates odds — ML/round-total values are REAL book numbers at a timestamp. Fighter records/physicals/style stats are REAL sourced facts. Model output (style edge, any grade) is always labeled MODEL. `Number(null)===0` is the trap — absent stat ⇒ absent, never 0. **Never ingest a fighter's photo/likeness** (same rule as headshots) — initials monogram only. Scraping fragility ⇒ degrade to absent, never fabricate.
## SCOPE — v1 (this wave) vs DEFERRED
**v1 ships:** fight-card discovery + tale-of-the-tape + style-blend archetypes + ML & round-total odds + a **style-edge VERDICT (a MODEL style read, explicitly NOT a settled grade)**.
**DEFERRED (own sub-wave, do NOT build now):** the matchup-GRADE engine that produces settled ML/method/round grades; method-of-victory & round & fighter props (data-limited on the free feed); combat outcome SETTLEMENT (no free box-score settle path yet — grades would stay `pending`); ufcstats.com scraping (needs a parser dep — CONFIRM before adding).
## DATA SOURCES (zero-out-of-pocket)
- **ESPN MMA (FREE, JSON, no auth — same family VYNDR already uses):**
- Fight cards / schedule: `site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard` (date-pinned like the other sports).
- Event / fight detail + results + tale-of-the-tape: `.../mma/ufc/summary?event={id}` and the athlete endpoints (`athlete.id`, record, stance, reach, weight class). ESPN athlete id also gives a headshot via the entity layer's `a.espncdn.com/i/headshots/mma/...` pattern — but per the likeness rule, v1 uses initials monograms; wire the id but default to monogram.
- Depth caveat (honest): ESPN MMA striking/grappling granularity is THINNER than ufcstats. Style-blend is best-effort from what ESPN exposes (finish history, method-of-victory counts, takedown/strike splits where present); when a stat is absent, the blend says less — never invents.
- **The Odds API `mma_mixed_martial_arts` (ALREADY PAID — `ODDS_API_KEY`):** wire `oddsService.SPORT_KEYS.mma = 'mma_mixed_martial_arts'` + `MMA_MARKETS = ['h2h','totals']` (moneyline + round totals). Reuse `oddsNormalizer`. **PropLine has NO combat** → odds-api-only; combat does NOT enter the abundant-props path.
## COMBAT ARCHETYPE REGISTRY (PINNED — both `src/services/archetypeService.js` AND `web/src/lib/archetypes.js` use these EXACT names/colors/glyphs; a test asserts they match, same as other sports). Colors unique WITHIN combat; may reuse hues used in other sports (cross-sport reuse is fine).
Six styles. `classify('mma', fighterStats)` returns `{ primary, secondary|null, blend:[{archetype,weight}] }` (same contract as other sports) scored from finish-rate / method splits / takedown & strike tendencies.
| Name | Glyph | Color | One-liner (shown where it leads) |
|---|---|---|---|
| STRIKER | ✦ | `#E8703A` | Wins on the feet — volume + power at range. |
| GRAPPLER | ⊗ | `#2FA4E7` | Fight hits the mat on his terms — control + subs. |
| PRESSURE | ➤ | `#E4574C` | Forward, relentless, breaks the pace. |
| COUNTER | ◊ | `#8E7BE0` | Patient — punishes what you show him. |
| FINISHER | ▲ | `#12B886` | Ends nights — high KO/SUB rate. |
| GRINDER | ▦ | `#B0883B` | Goes the distance, wins the rounds. |
STRIKER↔GRAPPLER is the primary range axis the mockup renders as the two blend bars; PRESSURE/COUNTER is tempo; FINISHER/GRINDER is the outcome tendency. A fighter is a BLEND (e.g. GRAPPLER 80% / STRIKER 30%). Discipline pedigree tags (Combat Sambo, Dagestan Wrestling, BJJ, Wrestling Base, Kickboxing, Muay Thai, Boxing) are **verifiable credentials**, rendered separately from the archetype blend — VERIFIABLE ONLY, mark unknowns absent, never guess a fighter's base.
## STYLE-MATCHUP VERDICT (v1 — a MODEL read, not a grade)
`styleMatchup(fighterA, fighterB)` → a style-edge verdict (e.g. "GRAPPLER EDGE · Islam") from comparing the two blends + finish/defense tendencies. This is a **descriptive MODEL read**, labeled as such — NOT a settled grade, NOT an edge %, NO fabricated confidence. The mockup's CENTER VERDICT. Keep it honest: if the data is too thin to call, say "STYLES EVEN / INSUFFICIENT READ".
## CONFIG WIRING (the recurring 4-layer-desync trap — do all in sync)
- `src/config/sports.js` `SPORT_CONFIG` + legacy `SPORTS` (flip `mma.active`), `web/src/config/sports.ts` mirror.
- `oddsService.SPORT_KEYS`/`SPORT_MARKETS` + `oddsNormalizer` MMA market keys.
- `src/config/statFilters.js` + `web/src/config/statFilters.ts` — combat stat categories (if any props surface).
- Combat is NOT added to `snapshotService.ACTIVE_SPORTS` in v1 (no settle path) — fight cards + odds + style card render on-read; grades stay OUT of the locked-snapshot loop until the deferred engine.
## FRONTEND (toward the mockup)
- **Tale-of-the-tape / head-to-head style card** — `web/src/components/vyndr/FightCard.tsx`: FIGHTER A (initials monogram, name, record e.g. 261, stance, reach) · GRAPPLER%/STRIKER% blend bars · discipline pedigree tags · archetype chip(s) via the shared `ArchetypeBadge` · CENTER VERDICT (style edge) · FIGHTER B mirror · a grades/odds row (MONEYLINE + round total from the odds feed; method/KO/SUB shown as "— data-limited" honest placeholders, NOT fabricated). Two fighters side-by-side (NOT the player-strip row grammar). Mono data, no glitch on data.
- **MMA badge/color** — the `#D4AF37` combat token already exists in `shareCards/tokens.js`; add a frontend MMA sport token + `SportBadge` support.
- **Route** — `web/src/app/fight/[id]/page.tsx` (server wrapper + client card) and/or a combat surface on the schedule; add Next proxies for the ESPN-MMA read (S25 rule). Self-hide / honest empty (reuse `EmptyState`) off-season.
- Sportsbook links via the existing `bookLinks.js`/affiliate layer unchanged.
## TESTS
- `archetypeService`/`archetypes.js` combat colors MATCH (extend the existing cross-file color test).
- `classify('mma', …)` blends correctly from fixtures; thin data → fewer/absent style claims (never fabricated).
- `styleMatchup` → honest "insufficient read" on thin data; a clear edge on divergent styles.
- combat adapter: defensive parse (ESPN shape → normalized fight card; unrecognized → empty, never throw); injectable, NO network.
- `oddsNormalizer` MMA market keys map (h2h/totals), else combat odds silently zero.
- FightCard self-hides / honest-empty; renders tale-of-the-tape mono; method/round shown as data-limited, not fabricated.
## OUT OF SCOPE REMINDER
No settled grades, no method/round/props board, no fighter photos, no scraping dep — all DEFERRED and flagged in-UI as data-limited rather than promised. The cheapest honest v1 is the goal: styles + tape + ML/round-totals, beautifully rendered.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
# VYNDR — WIRING & DATA TRAIN · STEP 0 MAP
### Branch `wiring/data-train`. The map before the build. Governing law: the $1M/mo feeling is PRODUCED, never claimed. Honesty guardrail: real asset where genuinely sourceable; honest fallback (monogram/text/absent) where not. Never fabricate or scrape a real person's face/data to fill a hole.
Legend: **✅ wire-able now** (data already in the system) · **🔧 sourceable free** (real source exists, needs a new adapter, zero-out-of-pocket) · **⛔ blocked** (no honest source today → keep fallback) · **🆕 net-new**.
---
## STEP 1 — TWO TRUST BUGS
### 1a. Billing "RENEWS 6/9/2036" — ✅ fixable
- **Render:** `web/src/app/profile/page.tsx:128` renders `subscription_end` verbatim from the Supabase `user_profiles` row. Honest render, bad data — no hardcoded 2036 in source.
- **Root cause:** the live web payment path is **NexaPay, not Stripe**. `web/src/app/api/webhook/nexapay/route.ts:41-50` writes `subscription_end = now + 30 days` (a synthetic guess). The 2036 value is a **manually-seeded/comped founder-account row** the UI trusts blindly. The *real* Stripe `current_period_end` exists at `GET /api/stripe/status` (`stripeService.js:278`) but the UI never consumes it.
- **Task:** point the "Renews" stat at a provider-asserted value (consume `/api/stripe/status.current_period_end`, or have the NexaPay webhook persist its real next-bill timestamp instead of now+30); correct/clear the stale 2036 row. Guard: never render a `subscription_end` the provider didn't assert → fall back to `—`.
### 1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision)
- **Root cause:** `mlbStatsAdapter.js:192` `people.find(p => nameKey(p.fullName) === targetKey)` returns the **first** exact-nameKey match with **no namesake disambiguation**. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins → wrong `currentTeam`. The S59 substring guard only covered *fuzzy* mismatches; it never covered two players sharing an exact name — **not a regression**, an uncovered case.
- **Propagation:** wrong team enters `snapshotService.js:315` `teamByPlayer` + rosterlogs opponents → `streaksService.js:229` `team` + opponents → `streakLens.js:79-82` "built vs" / tonight's-opponent. The S59 slate JOIN INVARIANT guards only Slate cards; the **streaks/rosterlogs path bypasses it**.
- **Task:** add an optional `teamHint` to `searchPlayer`/`getPlayerStats`/`resolveStats`; on multiple exact-nameKey matches pick the one whose `currentTeam` matches the hint, else return null (never guess — S59 doctrine). In `snapshotService.js:311` pass the prop's game team as the hint and drop `stats.team`/rawLog if it disagrees with the prop's game participants.
- **Test:** join-invariant — a resolved player's `team` must equal / be a participant of the prop's game; a rosterlogs entry's `team` must match the graded prop's team → omit on mismatch, never tag a foreign team.
---
## STEP 2 — ENTITY LAYER, FULLY FED
**Root-cause verdict:** team **logos already render live**. Player **headshots break at a data-threading gap, not a helper bug** — the MLBAM id is fetched then severed before it reaches the browser.
| Surface | Asset | Status | Task |
|---|---|---|---|
| Game-card team logos | ESPN logo CDN | ✅ live (`vyndr/GameCard.tsx:121` `TeamLogo`) | none; add any missing abbrs to `teamMeta.js` |
| MLB slate headshots | `img.mlbstatic.com/…/people/{id}/headshot` | ✅ id fetched (`playerIntelService.js:141`) but **severed at `snapshotService.js:331-336`** | thread `playerId` → enriched grade → `slateAdapter.buildPlayerStripsFromProps``StatStrip.tsx:381` `playerId={ps.playerId}` |
| MLB scan-search headshots | same | ✅ id already on `/api/players/search` `p.id` | pass `playerId:p.id` at `scan/page.tsx:566` (+ `SearchModal.tsx`) |
| MLB hot-list headshots | same | ✅ id via `rosterlogs` | `HotListPanel.tsx:68` uses raw silhouette `<img>` → swap for `PlayerAvatar` (branded monogram on null) |
| NBA/WNBA headshots | cdn.nba.com / ESPN athlete id | ⛔ **no real id in data** (search returns synthetic `sport-i-key`; `resolvePlayerStats` NBA branch emits none) | source a real id first, or keep honest monogram. NBA/WNBA don't settle yet → low ROI now |
| Soccer headshots | — | ⛔ no central CDN (by design) | honest monogram |
| Ledger book wordmarks | `BookWordmark` (brand-color text) | ✅ built but **never imported**; renders raw lowercase `row.book` at `ledger/page.tsx:368` | import `BookWordmark`; add 6 missing keys to `books.js` (`fanatics/bet365/hardrockbet/betrivers/pointsbet/pinnacle` fall to gray) |
- **Sportsbook wordmarks:** the work order asks for **local SVGs**. Current `BookWordmark` is brand-color *text*. Decision below (D3) — text-wordmark ships today; SVGs are an upgrade.
- **Headshot normalization:** `PlayerAvatar` already enforces size/round treatment; extend to guarantee consistent crop when a real photo loads. `onError`→monogram is first-class (no broken-image icon ever).
**Wire-able now: MLB headshots (3 surfaces) + ledger wordmarks. Blocked: NBA/WNBA headshots (no id source).**
---
## STEP 3 — OUTLOOK MODE (never-empty slate) — ✅ wire-able now
- DS2's `heroFallbackState`/`buildHeroReceipts` (`slateAdapter.js:467-495`) covers ONLY the "Top grades tonight" hero (`dashboard/page.tsx:314-418`) — never blank there. **Gap:** the `<Slate>` game grid (`Slate.tsx:1001-1013` `emptyStateCopy`) AND the dashboard's separate "Today's games" section (`page.tsx:421-439`) each independently fall to a dead-end "NO SLATE" card.
- The `"No games available…"` string at `Slate.tsx:609` is the **network `fetchError`** state, distinct from empty-slate.
- **Fill data already fetched:** yesterday's settled receipts (`/api/ledger/model`, already loaded), tomorrow's date-pinned schedule (`scheduleService`), ticker items, month-aware `emptyStateCopy`. Only the wiring into the grid branch is new.
- **Task:** extend the DS2 fallback below the hero — when `games.length===0`, render receipts / tomorrow's schedule preview inside the grid slot in both `Slate.tsx:1001` and `dashboard/page.tsx:424`, not a CTA card.
---
## STEP 4 — MISSING SURFACES
| Surface | Status | Verdict |
|---|---|---|
| **Live grade-shift timeline** | ✅ wire-able now | backend done (`intradayRefreshService` revisions + `revised_from_grade` + line history); reaches UI as per-row chip (`StatStrip.tsx:171`) + `LineSparkline`. Missing only a **timeline VIEW** assembled from already-emitted movement/history data. No new backend. |
| **Market-breadth / consensus-vs-model** | ✅ wire-able now | per-prop `books[]` (`Slate.tsx:373`) + `model_value` + snapshot line-deltas already present. Missing only a component that computes median-book-vs-model. `DeskShowcase.tsx:89` currently *advertises* it as copy — must become real or the claim goes. |
| **Parlay Lab builder** | ✅ math done, 🆕 UI | `parlayService`/`/api/parlay/grade`/`ParlayContext` all live; combined grade + correlation + payout render in `ParlayPanel`. "Empty tray" because the **only leg source is the "+" on live slate rows** and there's **no `/parlay` page**. Task: build a Parlay Lab page with a prop search/browse leg source independent of the slate. |
| **Pitcher arsenal** (mix/velo/usage/whiff%) | 🔧 sourceable free / 🆕 feed | **NOT in statsapi** — this is Statcast / Baseball Savant (free, public). Needs a genuinely new Savant adapter, not wiring. |
| **/u public profile** | ✅ route live, ⛔ no record | route + aggregate machinery work; `/u/vyndr` 200s to an honest not-found because **no `public_profiles` row is claimed+published** AND `getModelAggregate({userId})` needs **≥20 settled rows** under that user. Structural note: it scopes to a *user's own* ledger — the **house model record is `user_id=NULL`** and isn't surfaceable as a user profile without a "house profile" option. |
**Wire-able now:** grade-shift timeline, consensus strip, Parlay Lab UI. **New free feed:** pitcher arsenal (Savant). **Data-gated:** /u real record (needs a published house profile — see D2).
---
## STEP 5 — COMBAT (net-new, largest lift, sequenced last)
- **Verdict: genuinely net-new.** Only inert placeholders exist (`sports.js` `mma:{active:false,comingSoon}`, a share-card color, a dormant ESPN URL in `ESPNAdapter.js`, Pinnacle id 22). No pipeline/route/service/archetype/grade path. **No combat spec exists in the repo** — it must be authored as Step 5's first artifact.
- **PropLine has no combat** → combat cannot ride the abundant-props path; it's **odds-api-only**, which changes the quota story.
- **Free source option space (report-only, no pick):**
- **ESPN MMA** (`site.api.espn.com/…/mma/ufc/scoreboard|summary`) — free JSON, no auth, **same family VYNDR already uses**; fight cards/results/method/round + some bio. Thinner stat depth than ufcstats. The URL already sits dormant in `ESPNAdapter.js`.
- **ufcstats.com** — richest tale-of-the-tape + striking/grappling granularity, but **HTML scraping → needs a parser dep (cheerio) + fragile**; UFC-only.
- **The Odds API `mma_mixed_martial_arts`** — **already paid** (`ODDS_API_KEY`); realistically **moneyline + round totals** only. Method-of-victory/round/props are **thin-to-absent** on the standard feed → data-constrained, not just build-constrained.
- Wikipedia/Wikidata — bio backstop only.
- **Honesty caveats:** never ingest fighter photos (likeness/rights — same rule as headshots); scraping must degrade to *absent*, never fabricated 0; if only ML+round-total are free, the UI must not imply a full method/prop board.
- **Biggest architectural departure:** combat's unit is a **matchup, not a per-player prop** → the current `analyzeViaEngine1` per-prop feature-vector doesn't fit; a **style-matchup grade engine** is closer to a new engine than a config add. Settlement needs a new ESPN-MMA result path (like the pending WNBA box-score work).
- **Cheapest honest v1:** ESPN-MMA (free) + odds-api ML/round-totals (paid, existing) + a net-new style-blend archetype set + a matchup-grade path; method/round/props flagged data-limited.
---
## FOUNDER DECISIONS (LOCKED — 2026-07-13)
- **Global visual reference:** `specs/design-reference/vyndr-system.html` (the Claude Design "Vyndr System" mockup, + `support.js`). Build EVERY surface toward it — layouts, the missing surfaces, color treatments, component designs. Mockup surfaces confirmed present: edge board, hero grade reveal, VYNDR intelligence panel, player identity block, archetype glyphs (**74 marks · 8 sports** — an expansion target), pitcher identity (arsenal/whiff/velo), correlation builder + parlay slip, live grade-shift (grade-by-game-clock, history·last-24), public record /u (CLV-verified, portrait 1080×1350 + OG 1200×630), fighter tale-of-the-tape (FIGHTER A/VERDICT/FIGHTER B, moneyline/KO/decision, style archetypes), pricing tier, empty/error 404-bar. **ONE exception: keep the CURRENT live-site VYNDR wordmark/name, NOT the mockup's.** Live wordmark wins; everything else references the mockup.
- **D1 — OVERRIDE → sport-agnostic entity layer NOW.** Don't defer NBA/WNBA to monograms. Capture a stable athlete id per player **at ingestion for every league** (ESPN cross-sport athlete id is the likely single key covering NBA/WNBA/NFL/NHL/soccer; MLB keeps its MLBAM thread-through). ONE headshot resolver keyed by `(league, id)`. Monograms remain the honest fallback ONLY where an id genuinely can't resolve. If a league needs a new ingestion step for the id, build it. Report any league with NO free id source.
- **D2 — default:** /u house-mode reading the `user_id=NULL` public aggregate.
- **D3 — OVERRIDE → bundle the ~8 real book SVGs in the SAME wave (Wave 2).** Text wordmarks are only the stopgap; the SVGs are the real fix and are cheap.
- **D4 — default:** Baseball Savant adapter for pitcher arsenal (free).
- **D5 — default:** author the combat spec + ship the honest free v1 (ESPN-MMA + odds-api ML/round-totals; method/round/props flagged data-limited); defer the full matchup-grade engine to its own sub-wave; confirm before any scraping dep.
### ADDITION 1 — ASSET STORAGE (ingestion-time resolve)
Resolve id→URL **once at ingestion**; store the id + resolved-or-404 status in our data so we don't re-check per render. Browser caches the actual image via CDN cache headers (no per-load re-fetch, no per-render re-resolve). Self-hosting/proxying images is a LATER optimization — not now.
### ADDITION 2 — RECORD BY GRADE TIER (non-negotiable, core not cosmetic)
The record must be tracked + displayed **PER GRADE TIER** (A+ went X-Y, A X-Y, B X-Y, C X-Y — each its own hit-rate), everywhere the record shows (dashboard, /u, ledger). A single blended "67%" hides the proof: higher grade wins more often — the tier calibration IS the credibility. Sport/bet-type slices are a plus; per-tier is required. Extend `ledgerService.getModelAggregate` (and any schema/query it needs) to return per-tier buckets; render them on every record surface. `A+` stands alone; then A/B/C/D/F by first letter (mirror the existing `outcomeService` bucketing).
### ADDITION 3 — DESIGN FILE IS THE GLOBAL REFERENCE
Weave in essentially everything from the mockup across all surfaces (see reference note above). Wordmark is the sole exception.
## ORIGINAL DECISION DEFAULTS (superseded where noted above)
- **D1 — NBA/WNBA headshots:** no real id source in the data today, and those sports don't settle yet. **Default: ship MLB headshots now; keep honest branded monograms for NBA/WNBA until an id source is added.** (Alt: invest in sourcing cdn.nba.com/ESPN ids now.)
- **D2 — /u real record:** the CLV-verified profile can't show a house record as a *user* profile (house = `user_id=NULL`). **Default: add a "house/model" profile mode that reads the public `user_id=NULL` aggregate** (the partner-pitch weapon shows the real 55-19), keeping user profiles gated at n≥20. (Alt: claim+publish a house account and let it accrue 20 settled reads — slower, but no code path added.)
- **D3 — Book wordmarks:** **Default: ship the existing brand-color text `BookWordmark` now (kills lowercase "betmgm"), bundle local SVGs as a follow-up wave** (SVGs are the work order's ask but a bigger drop-in). (Alt: source + bundle the ~8 SVGs before shipping Step 2.)
- **D4 — Pitcher arsenal:** Baseball Savant is free → **Default: build the Savant adapter (zero-out-of-pocket).** Flag any field Savant doesn't cover as absent.
- **D5 — Combat scope:** **Default: author the combat spec + ship the free honest v1 (ESPN-MMA + odds-api ML/round-totals), flagging method/round/props as data-limited; defer the full matchup-grade engine to its own sub-wave.** Confirm before adding any scraping dep (ufcstats/cheerio).
---
## WAVE 2 BUILD PLAN (LOCKED — de-risk findings)
The `(league,id)`→URL resolver (`web/src/lib/playerHeadshot.ts` `getHeadshotUrl`) + `PlayerAvatar` (headshot-or-monogram via `<img onError>`) **already exist and are correct**. PropLine props carry NO id → the id can ONLY come from the **stats resolve the snapshot already runs per player** (`snapshotService.js:311`) = Addition 1's intent exactly. **Store `playerId`+`espnId` on the `enriched` grade objects at `snapshotService.js:331-336`** (beside `archetype`/`team`) → flows free to `grades:{sport}``buildPlayerStripsFromProps``StatStrip` `playerId`. Zero new Redis key, zero new I/O.
| League | Verdict | Wiring |
|---|---|---|
| MLB | ✅ now | MLBAM `res.id` already captured (into rosterlogs only) — add to `enriched`; thread to strip/scan/hotlist |
| NBA/WNBA | 🔧 free now | `espnStatsAdapter.getSeasonAverages` **resolves the ESPN athlete id at `:89` and discards it** — return it as `espnId`; surface via `resolvePlayerStats` nba/wnba branch; thread `espnId``getHeadshotUrl`; runtime-verify ESPN coverage |
| NFL/NHL | dormant | not in `ACTIVE_SPORTS` (+ off-season); add `nfl`/`nhl` to `ESPN_SPORT_PATH` (`playerHeadshot.ts:39`); inherits ESPN id when ingested |
| Soccer | ⛔ blocked | no live resolve branch; API-Football has `player.id` + `media.api-sports.io` CDN but is key-gated (`API_FOOTBALL_KEY`) + unwired → **honest monogram** until a soccer resolve branch + key land. **This is the one league to report as no-free-id-today.** |
**404-status (Addition 1):** id resolve is free; a true resolved-or-404 status needs an image HEAD probe (extra I/O). `<img onError>` already degrades per-render + the browser CDN-caches → **ship id-only first**; add a bounded HEAD probe in the `mapLimit` loop only if the one-frame monogram→photo flash is objectionable. Book SVGs (D3) + ledger `BookWordmark` import ride this wave too.
## WAVE SEQUENCE (ship on green, per step; toward the mockup)
1. **Wave 1 — Step 1 trust bugs** (billing renewal date + Wood nameKey-collision + streaks join-invariant test) + the quick DeskShowcase "A $1M terminal" → on-voice deadpan copy fix. Quick, independent, no mockup dep.
2. **Wave 2 — Step 2 entity layer, SPORT-AGNOSTIC (the eyeball gate).** (a) ingestion-time athlete-id capture per league (ESPN cross-sport id + MLB MLBAM) with resolved-or-404 status stored (Addition 1); (b) ONE `(league,id)`→headshot resolver; (c) thread id → grade → strip → every surface (slate strips, scan search, hot list, grade cards); (d) ledger book wordmarks — import `BookWordmark` + add missing keys + **bundle the ~8 real book SVGs**; (e) team-logo coverage gaps. **STOP + REPORT for founder eyeball of real logos/headshots across ALL sports before the rest builds on it.** Report any league with no free id source.
3. **Wave 3 — Record-by-grade-tier (Addition 2, core)** — extend `getModelAggregate` to per-tier buckets + render on dashboard, ledger, /u. Sets up /u + the credibility story.
4. **Wave 4 — Step 3 outlook mode** (never-empty grid → receipts/tomorrow) + Step 4 wire-able-now surfaces toward the mockup: live grade-shift timeline (grade-by-game-clock, history·last-24), market-breadth/consensus strip, Correlation Builder / Parlay Lab page (parlay slip + correlation flag).
5. **Wave 5 — /u house-mode profile** (D2, reads `user_id=NULL` aggregate; portrait + OG crops from the mockup) + **pitcher identity card** fed by the **Baseball Savant arsenal adapter** (D4, new free feed).
6. **Wave 6 — Step 5 combat** (author the combat spec FIRST → free honest v1: ESPN-MMA + odds-api ML/round-totals + style-blend archetype set + tale-of-the-tape card from the mockup; method/round/props flagged data-limited; matchup-grade engine its own sub-wave; confirm before any scraping dep).
Global rule for every wave: build the surface toward `specs/design-reference/vyndr-system.html`; keep the live wordmark. Ship each wave on green (tests + `next build` exit 0); nothing to main without the founder's word.
+6
View File
@@ -152,6 +152,12 @@ app.use('/api/widget', widgetRoutes);
// stat-filtered views over all of them. // stat-filtered views over all of them.
const scheduleRoutes = require('./routes/schedule'); const scheduleRoutes = require('./routes/schedule');
app.use('/api/schedule', scheduleRoutes); app.use('/api/schedule', scheduleRoutes);
// Wave 6 — combat intelligence (MMA/UFC): fight cards + tale-of-the-tape +
// best-effort ML/round-total odds. Read-only, cache-friendly, honest empty
// off-card. NOT in the graded-props pipeline (no settled grades in v1).
const combatRoutes = require('./routes/combat');
app.use('/api/combat', combatRoutes);
app.use('/api/fight', combatRoutes.fightRouter);
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public, // Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
// cache-only, never triggers a snapshot. // cache-only, never triggers a snapshot.
const tickerRoutes = require('./routes/ticker'); const tickerRoutes = require('./routes/ticker');
+5 -1
View File
@@ -41,7 +41,11 @@ const SPORTS = Object.freeze({
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, // Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
// tale-of-the-tape + style blend + ML/round-total odds). It is NOT in the
// graded-props pipeline (SPORT_CONFIG) or the snapshot/settle loop yet, so
// collectData stays false — active flips true so the UI treats it as live.
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
}); });
+81
View File
@@ -0,0 +1,81 @@
/**
* /api/combat/:date + /api/fight/:id (Wave 6 — combat intelligence, honest v1).
*
* Fight-card discovery + tale-of-the-tape + best-effort moneyline / round-total
* odds. FREE ESPN MMA feed for the cards; the paid odds-api feed for ML/round
* totals is BEST-EFFORT + cached (never fails the card). Combat is NOT in the
* snapshot/settle loop → NO settled grades here; method/round/KO are surfaced by
* the frontend as honest "— data-limited", never fabricated.
*
* Response (cards):
* { date, events: [ { id, name, shortName, date, venue,
* bouts: [ { id, weightClass, rounds, status, fighters: [ …tale ],
* odds: { moneyline, roundTotal } | null } ] } ], source }
*/
const express = require('express');
const combat = require('../services/adapters/combatAdapter');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
// Public throttle (60/min; ESPN is free, odds are cached — be respectful).
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Styles make fights' };
// Attach cached/best-effort odds onto each bout of each event.
function attachOdds(events, oddsMap) {
for (const ev of events || []) {
for (const bout of ev.bouts || []) {
const o = combat.matchBoutOdds(bout, oddsMap);
bout.odds = o ? { moneyline: o.moneyline, roundTotal: o.roundTotal } : null;
}
}
return events;
}
// GET /api/combat/:date — fight cards for an ET date (defaults to today).
router.get('/:date', async (req, res) => {
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.params.date || ''))
? req.params.date
: combat.todayET();
try {
const [cards, oddsMap] = await Promise.all([
combat.getFightCards(date),
combat.getCombatOdds().catch(() => ({})),
]);
attachOdds(cards.events, oddsMap);
res.set('Cache-Control', 'public, max-age=300');
return res.set(MISSION_HEADER).json(cards);
} catch (err) {
console.error('[combat/:date]', err && err.message);
// The board is never a crash — honest empty on failure.
return res.set(MISSION_HEADER).json({ date, events: [], source: 'espn' });
}
});
module.exports = router;
// Separate router for /api/fight/:id (a single card by event id).
const fightRouter = express.Router();
fightRouter.use(createRateLimit({ windowMs: 60_000, max: 60 }));
fightRouter.get('/:id', async (req, res) => {
const id = String(req.params.id || '').replace(/[^0-9]/g, '');
if (!id) return res.status(404).set(MISSION_HEADER).json({ error: 'not found' });
try {
const [card, oddsMap] = await Promise.all([
combat.getFightCard(id),
combat.getCombatOdds().catch(() => ({})),
]);
if (!card) return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
attachOdds([card], oddsMap);
res.set('Cache-Control', 'public, max-age=300');
return res.set(MISSION_HEADER).json({ event: card, source: 'espn' });
} catch (err) {
console.error('[fight/:id]', err && err.message);
return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
}
});
module.exports.fightRouter = fightRouter;
+63
View File
@@ -19,6 +19,13 @@
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit * PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle * toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
* return the byte-identical 404 body. * return the byte-identical 404 body.
*
* HOUSE PROFILE (Wave 5A, D2) — the reserved `HOUSE_HANDLE` (default `vyndr`)
* resolves to the PUBLIC model record (`getModelAggregate()` with NO userId →
* the `user_id = NULL` ledger rows). It needs NO public_profiles row and is
* ALWAYS public (it's the partner-pitch weapon: the real house record). It is
* the ONLY special case; every OTHER handle keeps the private-by-default,
* no-existence-leak contract intact.
*/ */
const express = require('express'); const express = require('express');
@@ -30,6 +37,10 @@ const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 })); router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const HANDLE_RE = /^[a-z0-9_]{3,20}$/; const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
// Reserved house handle → the public model record (user_id = NULL). Operators
// can override via env; it must still satisfy HANDLE_RE to be reachable.
const HOUSE_HANDLE = String(process.env.HOUSE_HANDLE || 'vyndr').trim().toLowerCase();
const HOUSE_LABEL = 'VYNDR MODEL · PUBLIC RECORD';
// Same 404 body for unknown AND unpublished — never confirm a handle exists. // Same 404 body for unknown AND unpublished — never confirm a handle exists.
const NOT_FOUND = { error: 'Profile not found' }; const NOT_FOUND = { error: 'Profile not found' };
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS). // Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
@@ -94,6 +105,10 @@ router.get('/:handle', async (req, res) => {
const handle = String(req.params.handle || '').trim().toLowerCase(); const handle = String(req.params.handle || '').trim().toLowerCase();
// Invalid shape can't exist (DB CHECK) → same 404, no query needed. // Invalid shape can't exist (DB CHECK) → same 404, no query needed.
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND); if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
// HOUSE handle → the public model record. Checked BEFORE the
// public_profiles lookup so the handle is reserved (a user who claims it is
// shadowed). This is the ONLY handle that bypasses the publish gate.
if (handle === HOUSE_HANDLE) return sendHouseProfile(res);
const sb = sbOrNull(); const sb = sbOrNull();
if (!sb) return res.status(404).json(NOT_FOUND); if (!sb) return res.status(404).json(NOT_FOUND);
try { try {
@@ -131,4 +146,52 @@ router.get('/:handle', async (req, res) => {
} }
}); });
/**
* The house/model profile — the PUBLIC model record (user_id = NULL), served
* as a shareable /u profile WITHOUT a public_profiles row. Same response shape
* the page already consumes (aggregate + by_tier + settled entries), plus
* `house: true` + a label so the UI can distinguish it from a user profile.
* It always "exists" → a fetch failure degrades to an honest empty/building
* state, never a 404.
*/
async function sendHouseProfile(res) {
const sb = sbOrNull();
try {
// NO userId → the public `user_id = NULL` aggregate (the real house
// record + Wave-3 by_tier). getModelAggregate self-empties without env.
const aggregate = await ledgerService.getModelAggregate(sb ? { sb } : {});
let entries = [];
if (sb) {
// ALL public settled reads, misses included — nothing curated.
const { data, error } = await sb.from('ledger_entries')
.select(ROW_COLUMNS)
.is('user_id', null)
.not('outcome', 'is', null)
.order('graded_at', { ascending: false })
.limit(ENTRY_LIMIT);
if (error) throw new Error(error.message);
entries = data || [];
}
res.set('Cache-Control', 'public, max-age=60');
return res.json({
handle: HOUSE_HANDLE,
house: true,
label: HOUSE_LABEL,
aggregate,
entries,
min_sample: ledgerService.MIN_AGG_SAMPLE,
});
} catch (err) {
console.error('[profiles/house]', err.message);
return res.status(200).json({
handle: HOUSE_HANDLE,
house: true,
label: HOUSE_LABEL,
aggregate: null,
entries: [],
min_sample: ledgerService.MIN_AGG_SAMPLE,
});
}
}
module.exports = router; module.exports = router;
+22 -1
View File
@@ -2,7 +2,8 @@ const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase'); const { getSupabaseServiceClient } = require('../utils/supabase');
const { getStatFilters } = require('../config/statFilters'); const { getStatFilters } = require('../config/statFilters');
const { createRateLimit } = require('../middleware/rateLimit'); const { createRateLimit } = require('../middleware/rateLimit');
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService'); const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService');
const { getPitcherArsenal } = require('../services/adapters/savantAdapter');
const depthChart = require('../services/depthChartService'); const depthChart = require('../services/depthChartService');
const router = express.Router(); const router = express.Router();
@@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => {
} }
}); });
// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B).
// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast
// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other
// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates.
router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'mlb').toLowerCase();
if (sport !== 'mlb') {
return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' });
}
const name = sanitizePlayerName(req.params.name);
const arsenal = await getPitcherArsenal(name);
res.set(MISSION_HEADER).json(arsenal || { found: false });
} catch (err) {
console.error('[stats/pitcher/arsenal]', err.message);
// Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides.
res.set(MISSION_HEADER).json({ found: false });
}
});
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top // GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
// graded props by confidence) for the Terminal / Stats Explorer. // graded props by confidence) for the Terminal / Stats Explorer.
router.get('/leaders', intelLimit, async (req, res) => { router.get('/leaders', intelLimit, async (req, res) => {
+358
View File
@@ -0,0 +1,358 @@
/**
* combatAdapter — ESPN MMA/UFC (FREE JSON, no auth) → normalized fight cards
* + tale-of-the-tape (Wave 6, combat intelligence).
*
* DATA SEMANTICS: fighter records / physicals are REAL sourced facts. We never
* fabricate. `Number(null) === 0` is the trap — an absent stat (reach, stance,
* finish counts) stays ABSENT (null), never coerced to 0. ESPN's MMA striking/
* grappling granularity is THINNER than ufcstats; when a field is absent the
* tape says less, never invents.
*
* Source: site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard
* - The scoreboard `events[]` are UFC CARDS. Each event carries many
* `competitions[]` — one per BOUT. Each bout has 2 competitors (fighters)
* with athlete name, W-L-D record, weight class, scheduled rounds, and the
* ESPN athlete id (parsed from the player-card link href). Stance/reach are
* NOT in the free scoreboard → left null (absent), wired for a later enrich.
*
* Odds (moneyline + round total) come from the odds-api MMA feed and are parsed
* by the PURE `normalizeCombatOdds` here (odds-api event shape → per-bout ML +
* round total). VYNDR never generates odds — these are REAL book numbers.
*
* Everything is defensive: an unrecognized shape yields an empty result, never
* a throw. `fetchImpl` is injectable so tests never touch the network.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { ALLOWED_BOOKS } = require('../../utils/oddsNormalizer');
const ESPN_MMA_SCOREBOARD = 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard';
const HTTP_TIMEOUT_MS = 10_000;
const CARDS_TTL = 15 * 60; // 15 min — cards move slowly; keep it cheap
const STALE_TTL = 6 * 3600; // stale-while-error fallback
/** Real finite number or null — the absent-beats-zero guard. */
function numOrNull(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Today's ET date (YYYY-MM-DD). Fight days roll on ET like the other sports. */
function todayET() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
/** ET date (YYYY-MM-DD) of an ISO timestamp, or null if unparseable. */
function dateET(iso) {
if (!iso) return null;
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(t);
}
/** Extract the ESPN athlete id from a fighter's link hrefs (…/id/4801725/…). */
function parseAthleteId(links) {
if (!Array.isArray(links)) return null;
for (const l of links) {
const m = /\/id\/(\d+)\//.exec(l && l.href ? String(l.href) : '');
if (m) return m[1];
}
return null;
}
/** Parse a "W-L-D" record summary into structured parts. Absent → nulls. */
function parseRecord(competitor) {
const recs = competitor && Array.isArray(competitor.records) ? competitor.records : [];
const overall = recs.find((r) => r && (r.type === 'total' || r.name === 'overall')) || recs[0];
const summary = overall && typeof overall.summary === 'string' ? overall.summary : null;
let wins = null, losses = null, draws = null;
if (summary) {
const m = /^(\d+)\s*-\s*(\d+)(?:\s*-\s*(\d+))?/.exec(summary.trim());
if (m) {
wins = numOrNull(m[1]);
losses = numOrNull(m[2]);
draws = m[3] != null ? numOrNull(m[3]) : 0;
}
}
// Display form (261 with an en-dash, or 2610 when a draw exists).
let display = null;
if (wins != null && losses != null) {
display = draws ? `${wins}${losses}${draws}` : `${wins}${losses}`;
}
return { wins, losses, draws, summary, display };
}
/** Normalize ONE fighter (an ESPN competitor). Defensive; unknown fields null. */
function normalizeFighter(competitor) {
if (!competitor || typeof competitor !== 'object') return null;
const a = competitor.athlete || {};
const name = a.displayName || a.fullName || a.shortName || null;
if (!name) return null;
return {
id: parseAthleteId(a.links),
name,
shortName: a.shortName || null,
record: parseRecord(competitor),
winner: competitor.winner === true ? true : (competitor.winner === false ? false : null),
// Physicals absent from the free scoreboard — kept for a future enrich.
stance: null,
reach: null,
};
}
/** Normalize ONE bout (an ESPN competition). Returns null on an unusable shape. */
function normalizeBout(comp) {
if (!comp || typeof comp !== 'object') return null;
const competitors = Array.isArray(comp.competitors) ? comp.competitors : [];
if (competitors.length < 2) return null;
const ordered = [...competitors].sort((x, y) => (x.order ?? 0) - (y.order ?? 0));
const fighters = ordered.map(normalizeFighter).filter(Boolean);
if (fighters.length < 2) return null;
const st = comp.status && comp.status.type ? comp.status.type : {};
return {
id: comp.id != null ? String(comp.id) : null,
weightClass: (comp.type && (comp.type.text || comp.type.abbreviation)) || null,
rounds: numOrNull(comp.format && comp.format.regulation && comp.format.regulation.periods),
status: st.state || null, // pre | in | post
completed: st.completed === true,
fighters,
};
}
/**
* Normalize a raw ESPN scoreboard payload into VYNDR fight cards. PURE +
* defensive — a shape it doesn't recognize returns { events: [] }, never throws.
*/
function normalizeScoreboard(raw, { date } = {}) {
const events = raw && Array.isArray(raw.events) ? raw.events : [];
const cards = [];
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue;
const evDate = ev.date || null;
// Date-pin defensively (same discipline as scheduleService S57): when a
// date is requested, only events on that ET date survive.
if (date && dateET(evDate) !== date) continue;
const comps = Array.isArray(ev.competitions) ? ev.competitions : [];
const bouts = comps.map(normalizeBout).filter(Boolean);
if (bouts.length === 0 && !ev.id) continue;
const comp0 = comps[0] || {};
cards.push({
id: ev.id != null ? String(ev.id) : null,
name: ev.name || null,
shortName: ev.shortName || null,
date: evDate,
dateET: dateET(evDate),
venue: (comp0.venue && (comp0.venue.fullName || comp0.venue.shortName)) || null,
bouts,
});
}
return { events: cards };
}
async function doFetch(url, fetchImpl) {
if (fetchImpl) return fetchImpl(url);
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
return res.data;
}
/**
* getFightCards(date, opts) — cache-aside UFC cards for an ET date.
* opts: { fetchImpl, cacheGet, cacheSet }. NO odds-api credits (ESPN is free).
* Off-card windows return { events: [] } (honest empty), never a throw.
*/
async function getFightCards(date = todayET(), opts = {}) {
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
const key = `combat:cards:${date}`;
try {
const cached = await cGet(key);
if (cached && Array.isArray(cached.events)) return { ...cached, source: 'cache' };
} catch { /* cache miss → live */ }
try {
const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?';
const url = `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(date).replace(/-/g, '')}`;
const raw = await doFetch(url, opts.fetchImpl);
const normalized = normalizeScoreboard(raw, { date });
const payload = { date, events: normalized.events, source: 'espn' };
try { await cSet(key, payload, CARDS_TTL); } catch { /* best-effort */ }
return payload;
} catch (err) {
// Stale-while-error, else honest empty.
try {
const stale = await cGet(key);
if (stale && Array.isArray(stale.events)) return { ...stale, source: 'stale' };
} catch { /* ignore */ }
console.warn('[combatAdapter] getFightCards failed:', err && err.message);
return { date, events: [], source: 'espn' };
}
}
/**
* getFightCard(eventId, opts) — one UFC card (all its bouts). The scoreboard
* carries every bout, so we locate the event by id. opts may pass `date` to
* pin the scoreboard fetch. Returns null when the id isn't found.
*/
async function getFightCard(eventId, opts = {}) {
const id = String(eventId || '');
if (!id) return null;
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
try {
const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?';
const url = opts.date
? `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(opts.date).replace(/-/g, '')}`
: ESPN_MMA_SCOREBOARD;
const key = `combat:card:${id}`;
if (!opts.fetchImpl) {
try {
const cached = await cGet(key);
if (cached && cached.id) return { ...cached, source: 'cache' };
} catch { /* miss */ }
}
const raw = await doFetch(url, opts.fetchImpl);
// Don't date-filter here — we're locating a specific event by id.
const normalized = normalizeScoreboard(raw, {});
const card = normalized.events.find((e) => e.id === id) || null;
if (card) { try { await cSet(key, card, CARDS_TTL); } catch { /* ignore */ } }
return card;
} catch (err) {
console.warn('[combatAdapter] getFightCard failed:', err && err.message);
return null;
}
}
/** Best (highest) American-odds price wins for a given side. Absent → null. */
function bestPrice(current, price) {
const p = numOrNull(price);
if (p == null) return current;
if (current == null) return p;
return p > current ? p : current; // best payout for the bettor
}
/**
* normalizeCombatOdds(eventsWithOdds) — PURE parse of the odds-api MMA event
* odds array into per-bout moneyline + round total. VYNDR never generates
* these — they are REAL book numbers. Only allow-listed US books count.
* Returns a map keyed by matchup ("fighterA|fighterB", lowercased) so the
* combat surface can join odds to the ESPN bout. Absent market → absent side.
*/
function normalizeCombatOdds(eventsWithOdds) {
const out = {};
const list = Array.isArray(eventsWithOdds) ? eventsWithOdds : [];
for (const ev of list) {
if (!ev || typeof ev !== 'object') continue;
const home = ev.home_team || null;
const away = ev.away_team || null;
if (!home && !away) continue;
const ml = { home: null, away: null };
const roundTotal = { line: null, over: null, under: null };
const books = Array.isArray(ev.bookmakers) ? ev.bookmakers : [];
for (const bk of books) {
if (!bk || !ALLOWED_BOOKS.has(bk.key)) continue;
const markets = Array.isArray(bk.markets) ? bk.markets : [];
for (const mk of markets) {
const outcomes = Array.isArray(mk && mk.outcomes) ? mk.outcomes : [];
if (mk.key === 'h2h') {
for (const o of outcomes) {
if (o.name === home) ml.home = bestPrice(ml.home, o.price);
else if (o.name === away) ml.away = bestPrice(ml.away, o.price);
}
} else if (mk.key === 'totals') {
for (const o of outcomes) {
const point = numOrNull(o.point);
if (point == null) continue;
if (roundTotal.line == null) roundTotal.line = point;
// Only pair the primary posted line (first seen).
if (point !== roundTotal.line) continue;
if (o.name === 'Over') roundTotal.over = bestPrice(roundTotal.over, o.price);
else if (o.name === 'Under') roundTotal.under = bestPrice(roundTotal.under, o.price);
}
}
}
}
const key = `${String(home || '').toLowerCase()}|${String(away || '').toLowerCase()}`;
out[key] = {
eventId: ev.id != null ? String(ev.id) : null,
home,
away,
commence_time: ev.commence_time || null,
moneyline: ml,
roundTotal: (roundTotal.over != null || roundTotal.under != null) ? roundTotal : null,
};
}
return out;
}
const ODDS_API_MMA_ODDS = 'https://api.the-odds-api.com/v4/sports/mma_mixed_martial_arts/odds';
const ODDS_TTL = 30 * 60; // 30 min — combat ML/round totals move slowly; conserve odds-api credits
/**
* getCombatOdds(opts) — BEST-EFFORT, CACHED combat moneyline + round totals.
* Cache-aside on `combat:odds`; on a cold cache it hits the odds-api MMA BULK
* odds endpoint ONCE (h2h + totals in a single request) rather than per-event,
* to conserve the paid odds-api quota. No key / any error → {} (odds absent;
* cards still render, the ML cell shows an honest "—"). Never throws.
* opts: { fetchImpl, cacheGet, cacheSet, apiKey }.
*/
async function getCombatOdds(opts = {}) {
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
const key = 'combat:odds';
try {
const cached = await cGet(key);
if (cached && typeof cached === 'object' && !opts.fetchImpl) return cached;
} catch { /* miss → live */ }
const apiKey = opts.apiKey || process.env.ODDS_API_KEY;
if (!apiKey && !opts.fetchImpl) return {};
try {
const url = `${ODDS_API_MMA_ODDS}?apiKey=${encodeURIComponent(apiKey || '')}&regions=us&markets=h2h,totals&oddsFormat=american`;
const raw = await doFetch(url, opts.fetchImpl);
const map = normalizeCombatOdds(Array.isArray(raw) ? raw : []);
try { await cSet(key, map, ODDS_TTL); } catch { /* best-effort */ }
return map;
} catch (err) {
console.warn('[combatAdapter] getCombatOdds failed:', err && err.message);
return {};
}
}
/**
* matchBoutOdds(bout, oddsMap) — join the odds map onto an ESPN bout by fighter
* names (either orientation). Returns the odds record or null. Never throws.
*/
function matchBoutOdds(bout, oddsMap) {
if (!bout || !oddsMap || typeof oddsMap !== 'object') return null;
const fs = Array.isArray(bout.fighters) ? bout.fighters : [];
if (fs.length < 2) return null;
const a = String(fs[0].name || '').toLowerCase();
const b = String(fs[1].name || '').toLowerCase();
return oddsMap[`${a}|${b}`] || oddsMap[`${b}|${a}`] || null;
}
module.exports = {
// read paths
getFightCards,
getFightCard,
getCombatOdds,
matchBoutOdds,
// pure, tested transforms
normalizeScoreboard,
normalizeBout,
normalizeFighter,
normalizeCombatOdds,
parseAthleteId,
parseRecord,
// helpers
todayET,
dateET,
numOrNull,
ESPN_MMA_SCOREBOARD,
};
+10
View File
@@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) {
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id)); const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
if (!id) return { found: false }; if (!id) return { found: false };
// Wave 2A — the REAL ESPN athlete id for the headshot CDN
// (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
// pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
// id, so it degrades to null → monogram. Never fabricate.
const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;
// 2. Fetch that athlete's stats overview. // 2. Fetch that athlete's stats overview.
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http); const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
const classifierInput = parseAthleteStats(stats); const classifierInput = parseAthleteStats(stats);
@@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) {
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '', team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
position: (athlete.position && athlete.position.abbreviation) || '', position: (athlete.position && athlete.position.abbreviation) || '',
classifierInput, classifierInput,
// Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
// slate strip → headshot. Absent → monogram (doctrine).
espnId,
}; };
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ } try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
return result; return result;
+121 -16
View File
@@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) {
return matchPlayers(people, query, opts.limit || 12); return matchPlayers(people, query, opts.limit || 12);
} }
async function searchPlayer(name, season = DEFAULT_SEASON) { // ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
const targetKey = nameKey(name); // Two different players can share an EXACT nameKey ("James Wood" — the Nationals
if (!targetKey) return null; // star + a Cubs-affiliate namesake). Taking the first `.find` match silently
const url = `${BASE}/sports/1/players?season=${season}`; // tagged the wrong team → wrong opponents → "built vs AL East" fabrication in
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); // streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a
const people = (data && Array.isArray(data.people)) ? data.people : []; // confident team hint (the prop's game participants); otherwise refuse (null).
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
if (!hit) { // The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs.
// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal.
const ABBR_ALIAS = Object.freeze({
AZ: 'ARI', ARI: 'ARI',
CHW: 'CWS', CWS: 'CWS',
WSN: 'WSH', WSH: 'WSH',
SDP: 'SD', SD: 'SD',
SFG: 'SF', SF: 'SF',
TBR: 'TB', TB: 'TB',
KCR: 'KC', KC: 'KC',
});
function canonAbbr(a) {
const u = String(a || '').toUpperCase().trim();
return ABBR_ALIAS[u] || u;
}
function teamNorm(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
/**
* Does a resolved team record (`{ id, name }`, from a candidate's currentTeam)
* match ANY identifier in the prop's team hint? A hint entry may be an abbr
* ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals").
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
* an abbr hint into a team id.
*/
function teamRecordMatchesHint(team, teamHint, teams) {
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
const tId = team.id;
const tName = teamNorm(team.name);
for (const h of teamHint) {
if (!h) continue;
// 1) hint as a name (equal / either-contains — handles "Nationals" vs full)
const hn = teamNorm(h);
if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true;
// 2) hint as an abbr → resolve to a team id via the teams list, compare ids
const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h));
if (rec && tId != null && rec.id === tId) return true;
}
return false;
}
/** Among namesake candidates, return the SINGLE one whose currentTeam matches
* the hint, else null (2+ or 0 matches → refuse; never guess). */
function disambiguateByHint(candidates, teamHint, teams) {
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
const matches = (candidates || []).filter((p) =>
p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams));
return matches.length === 1 ? matches[0] : null;
}
/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial
* hit, else null. A missing profile beats another player's log. */
function lastNameInitialFallback(people, targetKey) {
const parts = targetKey.split(' '); const parts = targetKey.split(' ');
const first = parts[0] || ''; const first = parts[0] || '';
const last = parts[parts.length - 1] || ''; const last = parts[parts.length - 1] || '';
if (first && last && first !== last) { if (!(first && last && first !== last)) return null;
const cands = people.filter((p) => { const cands = (people || []).filter((p) => {
const k = nameKey(p.fullName).split(' '); const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
}); });
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess return cands.length === 1 ? cands[0] : null;
}
/**
* Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game
* team identifiers) disambiguates namesakes AND enforces the join invariant:
* when a hint is present but the resolved player's team is NOT a participant of
* the prop's game, the team is DROPPED (returned null) rather than tagging a
* foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests.
*/
async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
const targetKey = nameKey(name);
if (!targetKey) return null;
let people = opts.people;
if (!Array.isArray(people)) {
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
people = (data && Array.isArray(data.people)) ? data.people : [];
}
const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null;
let teams = Array.isArray(opts.teams) ? opts.teams : null;
const ensureTeams = async () => {
if (teams) return teams;
try { teams = await getTeams(season); } catch { teams = []; }
return teams;
};
const exact = people.filter((p) => nameKey(p.fullName) === targetKey);
let hit = null;
let teamConfirmed = true; // stays true when there's no hint to check against
if (exact.length === 1) {
hit = exact[0];
if (teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
}
} else if (exact.length >= 2) {
// Namesake collision — resolve ONLY with a confident hint, else refuse.
hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null;
// a hit here is team-confirmed by construction.
} else {
hit = lastNameInitialFallback(people, targetKey);
if (hit && teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
} }
} }
if (!hit) return null; if (!hit) return null;
return { return {
id: hit.id, id: hit.id,
fullName: hit.fullName ?? name, fullName: hit.fullName ?? name,
team: hit.currentTeam?.name ?? null, team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
teamId: hit.currentTeam?.id ?? null, teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null,
position: hit.primaryPosition?.abbreviation ?? null, position: hit.primaryPosition?.abbreviation ?? null,
}; };
} }
@@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) {
* last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns * last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
* { found: false } on any miss/failure (never throws). * { found: false } on any miss/failure (never throws).
*/ */
async function getPlayerStats(name, season = DEFAULT_SEASON) { async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
try { try {
const person = await searchPlayer(name, season); const person = await searchPlayer(name, season, opts);
if (!person) return { found: false }; if (!person) return { found: false };
const group = person.position === 'P' ? 'pitching' : 'hitting'; const group = person.position === 'P' ? 'pitching' : 'hitting';
const [seasonStat, log] = await Promise.all([ const [seasonStat, log] = await Promise.all([
@@ -293,5 +394,9 @@ module.exports = {
getTeams, getTeams,
resolveTeam, resolveTeam,
getTeamRoster, getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, __internals: {
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
},
}; };
+277
View File
@@ -0,0 +1,277 @@
'use strict';
/**
* Baseball Savant / Statcast adapter (Wave 5B — Pitcher Arsenal).
*
* Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com —
* it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public.
* `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal
* is the missing "one identity → many props" lens (the mockup's PITCHER IDENTITY).
*
* ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter):
* - Free source → NO gateway / NO quota tracking.
* - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is
* needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape).
* - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff
* is ABSENT (null), NEVER 0 — `Number(null) === 0` is the fabrication trap.
* - Never fabricate: no pitcher → `{ found:false }`; the card self-hides.
* - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` /
* `opts.resolveId` / cacheGet/cacheSet) → tests never hit the network.
*
* SOURCE ENDPOINTS (public, `csv=true` → no dependency):
* 1. Pitch-arsenal-stats (usage% + whiff% + K%) — LONG format, one row per
* (pitcher, pitch_type):
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true
* 2. Pitch-arsenals avg velo (best-effort enrichment) — WIDE format, one row
* per pitcher with a `{abbr}_avg_speed` column per pitch:
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true
* Both are LEAGUE-WIDE → fetched once, cached 24h, indexed by MLBAM pitcher id.
* Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) —
* never a fabricated 0. NEEDS PROD VERIFICATION of the live column names.
*/
const axios = require('axios');
const { cacheGet: redisGet, cacheSet: redisSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 12_000;
const ARSENAL_TTL = 24 * 3600; // 24h — pitch mix is a slow-moving season identity
const LEAGUE_TTL = 24 * 3600;
const DEFAULT_SEASON = 2026;
const TOP_PITCHES = 6; // cap the strip — nobody throws more than ~6 real pitches
// In-memory mirror so a single process serves the merged per-pitcher arsenal
// without a Redis round-trip (and so it degrades when Redis is down).
const _mem = new Map(); // key -> { value, exp }
function memGet(key) {
const hit = _mem.get(key);
if (!hit) return null;
if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; }
return hit.value;
}
function memSet(key, value, ttl) {
_mem.set(key, { value, exp: Date.now() + ttl * 1000 });
}
function statsUrl(year) {
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`;
}
function veloUrl(year) {
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`;
}
// ── Strict numeric parsing — absent beats zero ──────────────────────────────
// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a
// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number
// percent only when the source is clearly a fraction (0..1).
function numOrNull(raw) {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().replace('%', '');
if (s === '' || s === 'NA' || s === 'null') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function pctOrNull(raw) {
const n = numOrNull(raw);
if (n === null) return null;
// Savant returns whole-number percents ("32.4"); a value in (0,1] is a
// fraction we scale up. Never invent a value where none exists.
return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n;
}
// ── Minimal quote-aware CSV parser (NO dependency) ──────────────────────────
// Savant's first column header is literally `"last_name, first_name"` (a quoted
// field containing a comma), so a naive split is wrong — we must honor quotes.
function parseCsvLine(line) {
const out = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false;
} else cur += ch;
} else if (ch === '"') {
inQ = true;
} else if (ch === ',') {
out.push(cur); cur = '';
} else cur += ch;
}
out.push(cur);
return out;
}
function parseCsv(text) {
if (typeof text !== 'string' || text.trim() === '') return [];
const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0);
if (rows.length < 2) return [];
const header = parseCsvLine(rows[0]).map((h) => h.trim());
const objs = [];
for (let r = 1; r < rows.length; r++) {
const cols = parseCsvLine(rows[r]);
if (cols.length === 0) continue;
const obj = {};
for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : '';
objs.push(obj);
}
return objs;
}
// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape → [].
function idOf(row) {
return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null;
}
// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ───────────
async function fetchText(url, opts) {
if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' });
return typeof res.data === 'string' ? res.data : null;
}
// stats leaderboard → { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] }
async function loadStatsIndex(year, opts) {
const cacheGet = opts.cacheGet || redisGet;
const cacheSet = opts.cacheSet || redisSet;
const key = `savant:arsenal-stats:${year}`;
let csv = opts.statsCsv;
if (csv === undefined) {
const cached = await cacheGet(key).catch(() => null);
if (cached && typeof cached === 'object') return cached; // already indexed
csv = await fetchText(statsUrl(year), opts).catch(() => null);
}
const rows = parseCsv(csv);
if (rows.length === 0) return null; // unrecognized / empty → caller decides
const index = {};
let usable = 0;
for (const row of rows) {
const id = idOf(row);
const type = row.pitch_type != null ? String(row.pitch_type).trim() : '';
if (id == null || String(id).trim() === '' || type === '') continue;
const key2 = String(id).trim();
(index[key2] = index[key2] || []).push({
type,
name: row.pitch_name ? String(row.pitch_name).trim() : type,
usagePct: pctOrNull(row.pitch_usage),
whiffPct: pctOrNull(row.whiff_percent),
kPct: pctOrNull(row.k_percent),
});
usable++;
}
if (usable === 0) return null; // header present but no id/pitch columns → unknown shape
if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
return index;
}
// velo leaderboard (WIDE) → { [pitcherId]: { ff: 99.1, sl: 87.0, ... } } (lowercased abbr → velo)
async function loadVeloIndex(year, opts) {
const cacheGet = opts.cacheGet || redisGet;
const key = `savant:arsenal-velo:${year}`;
let csv = opts.veloCsv;
if (csv === undefined) {
const cached = await cacheGet(key).catch(() => null);
if (cached && typeof cached === 'object') return cached;
csv = await fetchText(veloUrl(year), opts).catch(() => null);
}
const rows = parseCsv(csv);
if (rows.length === 0) return {}; // velo is OPTIONAL — absent index is fine
const index = {};
for (const row of rows) {
const id = idOf(row);
if (id == null || String(id).trim() === '') continue;
const speeds = {};
for (const col of Object.keys(row)) {
const m = /^([a-z]{1,3})_avg_speed$/i.exec(col);
if (!m) continue;
const v = numOrNull(row[col]);
if (v !== null) speeds[m[1].toLowerCase()] = v;
}
if (Object.keys(speeds).length) index[String(id).trim()] = speeds;
}
if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
return index;
}
/**
* Resolve a pitcher's arsenal by MLBAM id (preferred) or name.
* @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>}
* pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc.
* Always graceful — any miss / unrecognized shape → { found:false }.
*/
async function getPitcherArsenal(idOrName, opts = {}) {
try {
const year = opts.year || DEFAULT_SEASON;
// Resolve name → MLBAM id when we weren't given a numeric id.
let id = idOrName;
const isNumericId = id != null && /^\d+$/.test(String(id).trim());
if (!isNumericId) {
const name = String(idOrName || '').trim();
if (!name) return { found: false };
const resolveId = opts.resolveId || (async (n) => {
const mlb = opts.mlbAdapter || require('./mlbStatsAdapter');
const person = await mlb.searchPlayer(n).catch(() => null);
return person && person.id != null ? person.id : null;
});
id = await resolveId(name);
if (id == null) return { found: false };
}
const key = String(id).trim();
// Merged per-pitcher cache (Redis + in-memory mirror), only for the live path
// (injected CSV fixtures skip the cache so tests are deterministic).
const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined;
const memKey = `savant:arsenal:${key}:${year}`;
if (!usingFixtures) {
const m = memGet(memKey);
if (m) return m;
const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null);
if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; }
}
const statsIndex = await loadStatsIndex(year, opts);
if (!statsIndex) return { found: false }; // unrecognized / empty stats feed
const rows = statsIndex[key];
if (!Array.isArray(rows) || rows.length === 0) return { found: false };
// Velo is best-effort enrichment; a failure leaves velo absent (null).
// In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network —
// tests stay hermetic and velo is honestly absent.
let veloIndex = {};
if (opts.veloCsv !== undefined || !usingFixtures) {
try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; }
}
const speeds = (veloIndex && veloIndex[key]) || {};
const pitches = rows
.map((p) => ({
type: p.type,
name: p.name,
usagePct: p.usagePct,
velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null),
whiffPct: p.whiffPct,
kPct: p.kPct,
}))
// rank by usage desc; a null usage sorts last (never fabricated to 0)
.sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1))
.slice(0, TOP_PITCHES);
const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' };
if (!usingFixtures) {
memSet(memKey, result, ARSENAL_TTL);
await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {});
}
return result;
} catch (err) {
console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message);
return { found: false };
}
}
module.exports = {
getPitcherArsenal,
__internals: {
parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf,
loadStatsIndex, loadVeloIndex, statsUrl, veloUrl,
DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem,
},
};
+182 -3
View File
@@ -272,7 +272,40 @@ const ARCHETYPES = {
}, },
}; };
// ── Combat archetype registry (Wave 6 — MMA/UFC) ────────────────────
// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry
// (NOT merged into ARCHETYPES) for two reasons:
// 1. FINISHER collides with the soccer archetype name — combat FINISHER
// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES
// lookup is keyed by uppercase name with no sport dimension.
// 2. Combat FINISHER's green (#12B886) sits close to the signal green,
// which the colorContract gate forbids for shared player archetypes.
// Isolating combat keeps that gate (edge-green purity) intact while
// honoring the pinned combat palette.
// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP;
// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs),
// same discipline as the cross-sport color-match test.
const COMBAT_ARCHETYPES = {
STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet — volume + power at range.' },
GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: '⊗', axis: 'range', description: 'Fight hits the mat on his terms — control + subs.' },
PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➤', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' },
COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: '◊', axis: 'tempo', description: 'Patient — punishes what you show him.' },
FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: '▲', axis: 'outcome', description: 'Ends nights — high KO/SUB rate.' },
GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: '▦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' },
};
// Discipline pedigree tags — VERIFIABLE credentials only (rendered separately
// from the archetype blend). Never inferred/guessed: absent when unknown.
const DISCIPLINE_PEDIGREES = [
'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base',
'Kickboxing', 'Muay Thai', 'Boxing',
];
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0); const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
// Strict presence check — an ABSENT stat must not score an axis (Number(null)
// === 0 would fabricate a "0 output" claim). Only a real finite number counts.
const has = (v) => typeof v === 'number' && Number.isFinite(v);
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
/** NBA scorers — VYNDR Original keys. */ /** NBA scorers — VYNDR Original keys. */
function scoreNBA(s) { function scoreNBA(s) {
@@ -358,7 +391,76 @@ function scoreMLB(s) {
}; };
} }
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB }; /**
* MMA fighter scorer. Style is a BLEND across three axes:
* range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs)
* tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense)
* outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance)
*
* Best-effort from what the ESPN feed exposes; ESPN's striking/grappling
* granularity is THINNER than ufcstats. Every axis scores ONLY when its
* inputs are real finite numbers — thin data yields fewer style claims,
* never a fabricated one. Inputs (all optional):
* slpm/sapm sig strikes landed/absorbed per min
* strAcc/strDef striking accuracy / defense (0-1)
* tdAvg takedowns per 15
* subAvg sub attempts per 15
* koRate/subRate/decRate fraction of WINS by method (0-1)
* koWins/subWins/decWins method counts (rates derived if rates absent)
*/
function scoreMMA(s = {}) {
const out = {};
// ── range axis ──
if (has(s.slpm)) {
let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite
if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15;
if (v > 0) out.STRIKER = clamp01(v);
}
if (has(s.tdAvg) || has(s.subAvg)) {
let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control
if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5;
if (v > 0) out.GRAPPLER = clamp01(v);
}
// ── tempo axis ──
if (has(s.slpm) && has(s.sapm)) {
const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure
if (vol > 0) out.PRESSURE = vol;
}
if (has(s.strDef) || has(s.strAcc)) {
let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0;
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4;
if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter
if (v > 0) out.COUNTER = clamp01(v);
}
// ── outcome axis ──
const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s);
const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s);
const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s);
if (koR != null || subR != null) {
const finish = (koR || 0) + (subR || 0);
if (finish > 0) out.FINISHER = clamp01(finish);
}
if (decR != null && decR > 0) out.GRINDER = clamp01(decR);
return out;
}
// Derive a method rate from a win count when explicit rates are absent.
// Returns null (not 0) when totals are unknown — absent, never fabricated.
function deriveRate(count, s) {
if (!has(count)) return null;
const total = has(s.totalWins)
? s.totalWins
: (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0);
if (!total || total <= 0) return null;
return clamp01(count / total);
}
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA };
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */ /** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
function getArchetype(name) { function getArchetype(name) {
@@ -370,6 +472,13 @@ function getArchetype(name) {
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null; return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
} }
/** Look up a COMBAT archetype descriptor by name (case-insensitive). */
function getCombatArchetype(name) {
if (!name) return null;
const key = String(name).toUpperCase();
return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null;
}
/** /**
* Classify a player. Returns: * Classify a player. Returns:
* { sport, primary, secondary|null, blend: [{archetype, weight}] } * { sport, primary, secondary|null, blend: [{archetype, weight}] }
@@ -384,7 +493,12 @@ function classify(sport, stats = {}) {
.filter(([, v]) => v > 0) .filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
// Combat is HONEST-empty on thin data: no forced fallback archetype (the
// other sports fall back to a low-usage role, but inventing a fighter's
// style from no data would be a fabrication — spec §STYLE-MATCHUP).
const resolver = sp === 'mma' ? getCombatArchetype : getArchetype;
if (ranked.length === 0) { if (ranked.length === 0) {
if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR'; const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] }; return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
} }
@@ -393,23 +507,88 @@ function classify(sport, stats = {}) {
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1; const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) })); const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
const primary = getArchetype(ranked[0][0]); const primary = resolver(ranked[0][0]);
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4 const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
? getArchetype(ranked[1][0]) ? resolver(ranked[1][0])
: null; : null;
return { sport: sp, primary, secondary, blend }; return { sport: sp, primary, secondary, blend };
} }
/** Weight of an archetype within a blend (0 when absent). */
function blendWeight(blend, name) {
const hit = (blend || []).find((b) => b.archetype === name);
return hit ? hit.weight : 0;
}
// Accept either a classify() result ({ blend }) or a raw blend array.
function asBlend(x) {
if (Array.isArray(x)) return x;
if (x && Array.isArray(x.blend)) return x.blend;
return [];
}
/**
* styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's
* CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated
* confidence. Compares two style blends; when the data is too thin or the
* styles are too close to call, it says so honestly.
*
* a/b may be classify('mma', …) results or raw blend arrays.
* Returns { verdict, edgeSide: 'a'|'b'|null, summary }.
*/
const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER'];
const MIN_EDGE = 0.2; // below this stylistic gap → too close to call
function styleMatchup(a, b) {
const A = asBlend(a);
const B = asBlend(b);
if (A.length === 0 || B.length === 0) {
return {
verdict: 'INSUFFICIENT READ',
edgeSide: null,
summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.',
};
}
let best = null;
for (const ax of STYLE_AXES) {
const diff = blendWeight(A, ax) - blendWeight(B, ax);
if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff };
}
if (!best || Math.abs(best.diff) < MIN_EDGE) {
return {
verdict: 'STYLES EVEN',
edgeSide: null,
summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.',
};
}
const edgeSide = best.diff > 0 ? 'a' : 'b';
return {
verdict: `${best.ax} EDGE`,
edgeSide,
summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`,
};
}
const classifyNBA = (stats) => classify('nba', stats); const classifyNBA = (stats) => classify('nba', stats);
const classifyWNBA = (stats) => classify('wnba', stats); const classifyWNBA = (stats) => classify('wnba', stats);
const classifyMLB = (stats) => classify('mlb', stats); const classifyMLB = (stats) => classify('mlb', stats);
const classifyMMA = (stats) => classify('mma', stats);
module.exports = { module.exports = {
ARCHETYPES, ARCHETYPES,
COMBAT_ARCHETYPES,
DISCIPLINE_PEDIGREES,
getArchetype, getArchetype,
getCombatArchetype,
classify, classify,
classifyNBA, classifyNBA,
classifyWNBA, classifyWNBA,
classifyMLB, classifyMLB,
classifyMMA,
styleMatchup,
}; };
+13
View File
@@ -56,6 +56,11 @@ const SPORT_KEYS = {
// keys added in Session 31; NHL keys were added alongside this wiring. // keys added in Session 31; NHL keys were added alongside this wiring.
nfl: 'americanfootball_nfl', nfl: 'americanfootball_nfl',
nhl: 'icehockey_nhl', nhl: 'icehockey_nhl',
// MMA / UFC (Wave 6 — combat intelligence). odds-api sport key for the
// moneyline + round-total feed. PropLine carries NO combat → odds-api-only
// (combat never enters the abundant player-props path). Off-card windows
// return an empty events array and the combat surface self-hides honestly.
mma: 'mma_mixed_martial_arts',
// Soccer (Session 7j) — odds-api sport keys verified against // Soccer (Session 7j) — odds-api sport keys verified against
// https://the-odds-api.com/sports-odds-data/sports-apis.html // https://the-odds-api.com/sports-odds-data/sports-apis.html
soccer_wc: 'soccer_fifa_world_cup', soccer_wc: 'soccer_fifa_world_cup',
@@ -142,6 +147,10 @@ const SOCCER_MARKETS = [
'player_passes', 'player_passes',
'team_clean_sheet', 'team_clean_sheet',
]; ];
// MMA / UFC (Wave 6) — GAME-level markets only: moneyline (h2h) + round total
// (totals). No player props on the free feed → NO 'spreads' suffix (unlike the
// player-prop sports, whose buildMarketString appends spreads).
const MMA_MARKETS = ['h2h', 'totals'];
function buildMarketString(markets) { function buildMarketString(markets) {
return [...markets, 'spreads'].join(','); return [...markets, 'spreads'].join(',');
@@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({
mlb: buildMarketString(MLB_MARKETS), mlb: buildMarketString(MLB_MARKETS),
nfl: buildMarketString(NFL_MARKETS), nfl: buildMarketString(NFL_MARKETS),
nhl: buildMarketString(NHL_MARKETS), nhl: buildMarketString(NHL_MARKETS),
// MMA carries no player-prop spreads → join the game-level markets directly.
mma: MMA_MARKETS.join(','),
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
// Every soccer league code shares the same market set. // Every soccer league code shares the same market set.
...Object.fromEntries( ...Object.fromEntries(
@@ -499,6 +510,8 @@ module.exports = {
getCacheKey, getCacheKey,
SPORT_KEYS, SPORT_KEYS,
SOCCER_SPORT_KEYS, SOCCER_SPORT_KEYS,
// Wave 6 — combat game-level markets (moneyline + round total).
MMA_MARKETS,
// Session 16 — per-sport market scoping. // Session 16 — per-sport market scoping.
SPORT_MARKETS, SPORT_MARKETS,
getMarketsForSport, getMarketsForSport,
+7 -2
View File
@@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
try { try {
if (sp === 'mlb') { if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const res = await mlb.getPlayerStats(name); // Wave 1 — thread the prop's game team hint so a namesake collision
// (two "James Wood") resolves to the RIGHT player, and a team that isn't
// a participant of the prop's game is dropped (never a foreign tag).
const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
if (!res || !res.found) return { found: false }; if (!res || !res.found) return { found: false };
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season); const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G) // Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
@@ -162,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) {
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) }); if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] }; // Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
// adapter. Absent → no id → monogram. Never guessed.
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
} }
return { found: false }; return { found: false };
} }
+35 -5
View File
@@ -298,6 +298,25 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game). // and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {}; const teamByPlayer = {};
// Wave 2A — the REAL athlete id from the SAME stats resolve, keyed by player.
// MLB → MLBAM id (mlbstatic headshot CDN); NBA/WNBA → ESPN athlete id
// (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to
// grades:{sport} → slate strips → PlayerAvatar. Zero new I/O. Absent → the
// component falls to a team-colored monogram (never a fabricated face).
const playerIdByPlayer = {};
const espnIdByPlayer = {};
// Wave 1 (trust bug) — the prop's game participants become the resolve's
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when
// the resolved player's real team isn't in the prop's game, the resolver drops
// the team rather than tag a foreign one (the streaks/rosterlogs JOIN
// INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name.
const teamHintByPlayer = {};
for (const p of props || []) {
const k = norm(p.player);
if (!k || teamHintByPlayer[k]) continue;
const hint = [p.home_team, p.away_team].filter(Boolean);
if (hint.length) teamHintByPlayer[k] = hint;
}
// Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks + // Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks +
// hot lists) starved because its data producers were all external and // hot lists) starved because its data producers were all external and
// unarmed (tank01-prefetch via n8n, the offline Python grading flow). // unarmed (tank01-prefetch via n8n, the offline Python grading flow).
@@ -308,11 +327,15 @@ async function runSnapshot(sport, opts = {}) {
const logEntries = []; const logEntries = [];
await mapLimit(players, STATS_CONCURRENCY, async (player) => { await mapLimit(players, STATS_CONCURRENCY, async (player) => {
try { try {
const stats = await deps.resolveStats(player, sp); const teamHint = teamHintByPlayer[norm(player)] || null;
const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {});
if (stats && stats.found) { if (stats && stats.found) {
const c = deps.classify(sp, stats.classifierInput || {}); const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null; archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team; if (stats.team) teamByPlayer[player] = stats.team;
// Wave 2A — capture the resolved athlete id (headshot thread).
if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId;
if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId;
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) { if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
logEntries.push({ logEntries.push({
name: normalizeName(player).display || player, name: normalizeName(player).display || player,
@@ -328,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
}); });
await mergeRosterLogs(sp, logEntries, deps); await mergeRosterLogs(sp, logEntries, deps);
const enriched = graded.map((g) => ({ const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
...g, ...g,
gradedAt: gradedAtFor(g, oddsByKey, ts), gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null, archetype: archByPlayer[pn] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || null, team: teamByPlayer[pn] || g.team || null,
})); // Wave 2A — real headshot id (MLBAM for MLB, ESPN for NBA/WNBA), threaded
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
};
});
// Line deltas vs the previous snapshot's locked lines. // Line deltas vs the previous snapshot's locked lines.
const prev = await deps.cacheGet(`snapshot:${sp}:latest`); const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
+10
View File
@@ -83,6 +83,16 @@ const MARKET_MAP = {
// are shared with soccer/NBA — sport context discriminates downstream. // are shared with soccer/NBA — sport context discriminates downstream.
player_shots_on_goal: 'shots_on_goal', player_shots_on_goal: 'shots_on_goal',
goalie_saves: 'saves', goalie_saves: 'saves',
// MMA / UFC (Wave 6 — combat intelligence). Game-level markets, NOT player
// props: h2h = moneyline (per fighter), totals = round total (over/under).
// Mapped here so combat odds don't silently normalize to zero when wired
// through the odds-api path (same silent-failure class as the MLB/NHL gaps).
// The combat surface parses these via combatAdapter.normalizeCombatOdds —
// normalizeProps (this file) is player-prop-shaped and skips them, which is
// correct: MMA carries no per-player point props on the free feed.
h2h: 'moneyline',
totals: 'round_total',
}; };
function normalizeProps(eventsWithOdds) { function normalizeProps(eventsWithOdds) {
+48
View File
@@ -0,0 +1,48 @@
// Wave 1 trust bug — honest renewal render. classifyRenewal must never let a
// far-future / comped `subscription_end` (the "RENEWS 6/9/2036" lie) render as
// a real monthly renewal, and must fail closed to `unknown` on bad input.
const { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS } = require('../../web/src/lib/billingDisplay');
const NOW = Date.parse('2026-07-13T00:00:00.000Z');
const DAY = 86_400_000;
describe('classifyRenewal — honest billing render', () => {
test('MONTHLY_RENEWAL_MAX_DAYS is 60', () => {
expect(MONTHLY_RENEWAL_MAX_DAYS).toBe(60);
});
test('the 2036 comped/seed row is NOT a renewal → none', () => {
const r = classifyRenewal('2036-06-09T00:00:00.000Z', NOW);
expect(r.kind).toBe('none');
expect(r.iso).toBeUndefined();
});
test('a plausible monthly next-bill (now + 30d) → date, carries iso', () => {
const r = classifyRenewal(new Date(NOW + 30 * DAY).toISOString(), NOW);
expect(r.kind).toBe('date');
expect(typeof r.iso).toBe('string');
expect(Date.parse(r.iso)).toBe(NOW + 30 * DAY);
});
test('absent value (null / undefined / empty) → unknown', () => {
expect(classifyRenewal(null, NOW).kind).toBe('unknown');
expect(classifyRenewal(undefined, NOW).kind).toBe('unknown');
expect(classifyRenewal('', NOW).kind).toBe('unknown');
});
test('a renewal well in the past → lapsed', () => {
const r = classifyRenewal(new Date(NOW - 10 * DAY).toISOString(), NOW);
expect(r.kind).toBe('lapsed');
});
test('malformed date string → unknown (never coerced to an epoch date)', () => {
expect(classifyRenewal('not a date', NOW).kind).toBe('unknown');
expect(classifyRenewal('N/A', NOW).kind).toBe('unknown');
});
test('exactly at the 60-day boundary still reads as a date; just beyond → none', () => {
expect(classifyRenewal(new Date(NOW + 60 * DAY).toISOString(), NOW).kind).toBe('date');
expect(classifyRenewal(new Date(NOW + 61 * DAY).toISOString(), NOW).kind).toBe('none');
});
});
+118
View File
@@ -0,0 +1,118 @@
// Wave 2B (data train) — sportsbook wordmarks. Every book the odds feed emits
// resolves to a real brand (name + non-gray color); the ~8 major books render a
// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare
// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not
// copied trademarked logos), swappable for official press-kit art later.
const fs = require('fs');
const path = require('path');
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle'];
const DEFAULT_FG = '#B8BCC8';
const REPO = path.resolve(__dirname, '../..');
const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8');
describe('bookInfo — every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => {
test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => {
const b = bookInfo(key);
// name must be a real brand, not the raw uppercased key echoed back
// (compare the ACTUAL name to the uppercased key — do NOT uppercase the
// name, or a correct casing-only brand like "DraftKings" would trivially
// equal "DRAFTKINGS" and false-fail; "bet365" ≠ "BET365" still passes).
expect(b.name).toBeTruthy();
expect(b.name).not.toBe(key.toUpperCase());
// color must not be the neutral-gray default
expect(b.fg).toBeTruthy();
expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase());
// never a bare lowercase feed key echoed as the display name — EXCEPT
// bet365, whose official brand wordmark genuinely is lowercase "bet365".
if (key !== 'bet365') expect(b.name).not.toBe(key);
});
test('the 6 previously-missing keys now resolve (were gray)', () => {
expect(bookInfo('fanatics').name).toBe('Fanatics');
expect(bookInfo('bet365').name).toBe('bet365');
expect(bookInfo('hardrockbet').name).toBe('Hard Rock');
expect(bookInfo('betrivers').name).toBe('BetRivers');
expect(bookInfo('pointsbet').name).toBe('PointsBet');
expect(bookInfo('pinnacle').name).toBe('Pinnacle');
});
test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => {
expect(bookInfo('DraftKings').name).toBe('DraftKings');
expect(bookInfo('DK').name).toBe('DraftKings');
expect(bookInfo('draftkings').name).toBe('DraftKings');
});
});
describe('bundled book wordmark SVGs', () => {
const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers'];
test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => {
expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort());
});
test.each(EXPECTED)('web/public/books/%s.svg exists and is a real <svg>', (slug) => {
const svg = read(`web/public/books/${slug}.svg`);
expect(svg).toMatch(/<svg[\s\S]*<\/svg>/);
});
test('bookSlug resolves feed keys + codes to the bundled slug', () => {
expect(bookSlug('draftkings')).toBe('draftkings');
expect(bookSlug('DK')).toBe('draftkings');
expect(bookSlug('hardrockbet')).toBe('hardrockbet');
expect(hasBookSvg('betmgm')).toBe(true);
expect(hasBookSvg('MGM')).toBe(true);
});
test('a book with no bundled SVG (fanatics) still has NO svg but a real name', () => {
expect(hasBookSvg('fanatics')).toBe(false);
expect(bookInfo('fanatics').name).toBe('Fanatics');
});
test('unknown book → no svg, no crash', () => {
expect(hasBookSvg('zzzbook')).toBe(false);
expect(bookSlug('zzzbook')).toBeNull();
});
});
describe('BookWordmark source — SVG-first, styled-text fallback, no lowercase leak', () => {
const src = read('web/src/components/vyndr/BookWordmark.tsx');
test('references the local /books/{slug}.svg path', () => {
expect(src).toContain('/books/');
expect(src).toMatch(/\$\{slug\}\.svg/);
});
test('resolves via the books registry (hasBookSvg + bookInfo + bookSlug)', () => {
expect(src).toContain('hasBookSvg');
expect(src).toContain('bookInfo');
expect(src).toContain('bookSlug');
});
test('falls back to the brand NAME (never the raw lowercase key)', () => {
// the fallback renders b.name (properly-cased brand), not the raw `book` prop
expect(src).toContain('b.name');
expect(src).not.toMatch(/>\s*\{book\}\s*</); // never render the raw prop verbatim
});
});
describe('ledger + public-profile rows render BookWordmark, not bare row.book', () => {
test('ledger page imports + uses BookWordmark', () => {
const src = read('web/src/app/ledger/page.tsx');
expect(src).toMatch(/import\s*\{[^}]*BookWordmark[^}]*\}\s*from\s*'@\/components\/vyndr'/);
expect(src).toContain('<BookWordmark book={row.book}');
// the old bare-text render is gone
expect(src).not.toContain("{row.book || '—'}");
});
test('public profile page imports + uses BookWordmark', () => {
const src = read('web/src/app/u/[handle]/PublicProfile.tsx');
expect(src).toContain('BookWordmark');
expect(src).toContain('<BookWordmark book={row.book}');
expect(src).not.toContain("{row.book || '—'}");
});
});
+185
View File
@@ -0,0 +1,185 @@
// Wave 6 — combatAdapter defensive parse. Fixtures modeled on the REAL ESPN
// MMA scoreboard shape (site.api.espn.com/.../mma/ufc/scoreboard) captured
// live during the build. NO network — fetchImpl + cache are injected.
const combat = require('../../src/services/adapters/combatAdapter');
// A trimmed but structurally-faithful ESPN MMA scoreboard payload: one UFC
// event ("card") with two bouts. Athlete ids live in the player-card link href.
const ESPN_FIXTURE = {
events: [
{
id: '600059599',
name: 'UFC Fight Night: Du Plessis vs. Usman',
shortName: 'UFC Fight Night',
date: '2026-07-18T21:00Z',
competitions: [
{
id: '1',
type: { id: '1007', abbreviation: 'W Flyweight', text: "Women's Flyweight" },
format: { regulation: { periods: 5 } },
venue: { fullName: 'UFC APEX' },
status: { type: { state: 'pre', completed: false } },
competitors: [
{
id: '10', order: 0, winner: false,
athlete: {
fullName: 'Dricus du Plessis', displayName: 'Dricus du Plessis', shortName: 'D. du Plessis',
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/4801725/dricus-du-plessis' }],
},
records: [{ name: 'overall', type: 'total', summary: '22-2-0' }],
},
{
id: '11', order: 1, winner: false,
athlete: {
fullName: 'Kamaru Usman', displayName: 'Kamaru Usman', shortName: 'K. Usman',
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/3088843/kamaru-usman' }],
},
records: [{ name: 'overall', type: 'total', summary: '20-4-0' }],
},
],
},
{
id: '2',
type: { abbreviation: 'Lightweight' },
format: { regulation: { periods: 3 } },
competitors: [
{ id: '20', order: 0, athlete: { displayName: 'Fighter A', links: [] }, records: [{ type: 'total', summary: '10-0' }] },
{ id: '21', order: 1, athlete: { displayName: 'Fighter B', links: [] }, records: [{ type: 'total', summary: '8-3' }] },
],
},
],
},
],
};
describe('combatAdapter.normalizeScoreboard — ESPN shape → fight cards', () => {
it('normalizes a real-shaped payload into a card with bouts + tale-of-tape', () => {
const { events } = combat.normalizeScoreboard(ESPN_FIXTURE);
expect(events).toHaveLength(1);
const card = events[0];
expect(card.id).toBe('600059599');
expect(card.name).toMatch(/Du Plessis/);
expect(card.bouts).toHaveLength(2);
const bout = card.bouts[0];
expect(bout.weightClass).toBe("Women's Flyweight");
expect(bout.rounds).toBe(5);
expect(bout.fighters).toHaveLength(2);
const a = bout.fighters[0];
expect(a.name).toBe('Dricus du Plessis');
expect(a.id).toBe('4801725'); // parsed from the link href
expect(a.record.wins).toBe(22);
expect(a.record.losses).toBe(2);
expect(a.record.display).toBe('222'); // en-dash display form
// Physicals absent from the free feed → null, NEVER fabricated 0.
expect(a.stance).toBeNull();
expect(a.reach).toBeNull();
});
it('date-pins defensively — an off-date event is dropped', () => {
const onDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-07-18' });
expect(onDate.events).toHaveLength(1);
const offDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-01-01' });
expect(offDate.events).toHaveLength(0);
});
it('DEFENSIVE: unrecognized/garbage shapes return empty, never throw', () => {
expect(() => combat.normalizeScoreboard(null)).not.toThrow();
expect(combat.normalizeScoreboard(null).events).toEqual([]);
expect(combat.normalizeScoreboard({}).events).toEqual([]);
expect(combat.normalizeScoreboard({ events: 'nope' }).events).toEqual([]);
// An identifiable event with only unusable bouts is kept with empty bouts.
expect(combat.normalizeScoreboard({ events: [{ id: 'x', competitions: [{ competitors: [{}] }] }] }).events[0].bouts).toEqual([]);
// An event with no id AND no usable bouts is dropped entirely.
expect(combat.normalizeScoreboard({ events: [{ competitions: [{ competitors: [{}] }] }] }).events).toEqual([]);
});
it('numOrNull never coerces null/empty to 0 (the fabrication trap)', () => {
expect(combat.numOrNull(null)).toBeNull();
expect(combat.numOrNull('')).toBeNull();
expect(combat.numOrNull(undefined)).toBeNull();
expect(combat.numOrNull('5')).toBe(5);
expect(combat.numOrNull(0)).toBe(0);
});
it('parseAthleteId is defensive on bad links', () => {
expect(combat.parseAthleteId(null)).toBeNull();
expect(combat.parseAthleteId([{ href: 'no-id-here' }])).toBeNull();
expect(combat.parseAthleteId([{ href: '/mma/fighter/_/id/999/x' }])).toBe('999');
});
});
describe('combatAdapter.getFightCards — injectable, no network', () => {
it('fetches via injected fetchImpl + normalizes (cache stubbed)', async () => {
const calls = [];
const res = await combat.getFightCards('2026-07-18', {
fetchImpl: async (url) => { calls.push(url); return ESPN_FIXTURE; },
cacheGet: async () => null,
cacheSet: async () => true,
});
expect(res.events).toHaveLength(1);
expect(calls[0]).toMatch(/dates=20260718/);
});
it('returns honest empty on a fetch error, never throws', async () => {
const res = await combat.getFightCards('2026-07-18', {
fetchImpl: async () => { throw new Error('network'); },
cacheGet: async () => null,
cacheSet: async () => true,
});
expect(res.events).toEqual([]);
});
});
describe('combatAdapter.normalizeCombatOdds — odds-api MMA → ML + round total', () => {
const ODDS_FIXTURE = [
{
id: 'evt1', commence_time: '2026-07-18T21:00Z',
home_team: 'Dricus du Plessis', away_team: 'Kamaru Usman',
bookmakers: [
{
key: 'draftkings',
markets: [
{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -230 }, { name: 'Kamaru Usman', price: 190 }] },
{ key: 'totals', outcomes: [{ name: 'Over', price: -110, point: 2.5 }, { name: 'Under', price: -110, point: 2.5 }] },
],
},
{
key: 'fanduel',
markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -215 }, { name: 'Kamaru Usman', price: 200 }] }],
},
// A non-allow-listed book must be ignored.
{ key: 'bovada', markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -999 }] }] },
],
},
];
it('maps h2h to per-fighter moneyline (best price) + totals to round total', () => {
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
const rec = map['dricus du plessis|kamaru usman'];
expect(rec).toBeDefined();
expect(rec.moneyline.home).toBe(-215); // best (higher) of -230 / -215
expect(rec.moneyline.away).toBe(200); // best of 190 / 200
expect(rec.roundTotal.line).toBe(2.5);
expect(rec.roundTotal.over).toBe(-110);
});
it('ignores non-allow-listed books (bovada never leaks a price)', () => {
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
expect(map['dricus du plessis|kamaru usman'].moneyline.home).not.toBe(-999);
});
it('matchBoutOdds joins in either name orientation', () => {
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
const bout = { fighters: [{ name: 'Kamaru Usman' }, { name: 'Dricus du Plessis' }] };
expect(combat.matchBoutOdds(bout, map)).toBeTruthy();
});
it('DEFENSIVE: garbage odds input returns {}, never throws', () => {
expect(() => combat.normalizeCombatOdds(null)).not.toThrow();
expect(combat.normalizeCombatOdds(null)).toEqual({});
expect(combat.normalizeCombatOdds([{ bookmakers: 'x' }])).toEqual({});
});
});
+108
View File
@@ -0,0 +1,108 @@
// Wave 6 — combat intelligence: archetype registry cross-file match,
// classify('mma') blends from fixtures (thin data → fewer claims, never
// fabricated), and styleMatchup honesty. NO network.
const svc = require('../../src/services/archetypeService');
const arch = require('../../web/src/lib/archetypes');
describe('combat archetype registry — pinned + cross-file agreement', () => {
const PINNED = {
STRIKER: { color: '#E8703A', glyph: '✦' },
GRAPPLER: { color: '#2FA4E7', glyph: '⊗' },
PRESSURE: { color: '#E4574C', glyph: '➤' },
COUNTER: { color: '#8E7BE0', glyph: '◊' },
FINISHER: { color: '#12B886', glyph: '▲' },
GRINDER: { color: '#B0883B', glyph: '▦' },
};
it('backend COMBAT_ARCHETYPES carries exactly the six pinned styles', () => {
expect(Object.keys(svc.COMBAT_ARCHETYPES).sort()).toEqual(Object.keys(PINNED).sort());
});
it('backend colors + glyphs match the pinned spec', () => {
for (const [name, p] of Object.entries(PINNED)) {
expect(svc.COMBAT_ARCHETYPES[name].color).toBe(p.color);
expect(svc.COMBAT_ARCHETYPES[name].glyph).toBe(p.glyph);
}
});
it('frontend COMBAT_ARCHETYPE_MAP colors + glyph chars MATCH the backend', () => {
for (const [name, a] of Object.entries(svc.COMBAT_ARCHETYPES)) {
const front = arch.COMBAT_ARCHETYPE_MAP[name];
expect(front).toBeDefined();
expect(front.c).toBe(a.color);
expect(front.char).toBe(a.glyph);
}
// No extra frontend combat archetypes beyond the pinned six.
expect(Object.keys(arch.COMBAT_ARCHETYPE_MAP).sort()).toEqual(Object.keys(svc.COMBAT_ARCHETYPES).sort());
});
it('combat FINISHER is namespaced — it does NOT collide with the soccer FINISHER', () => {
// Soccer FINISHER stays #FF5C5C in the shared map; combat FINISHER is #12B886.
expect(arch.ARCHETYPE_MAP.FINISHER.c).toBe('#FF5C5C');
expect(arch.combatArchetypeColor('FINISHER')).toBe('#12B886');
// sport-aware resolution keeps them apart:
expect(arch.archetypeColor('FINISHER')).toBe('#FF5C5C'); // no sport → soccer
expect(arch.archetypeColor('FINISHER', 'mma')).toBe('#12B886'); // mma → combat
});
});
describe("classify('mma', …) — blends from fixtures", () => {
it('a high-volume distance striker profiles STRIKER-primary', () => {
const r = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, strDef: 0.62, tdAvg: 0.2, koRate: 0.6, decRate: 0.3 });
expect(r.primary && r.primary.name).toBe('STRIKER');
expect(r.blend.length).toBeGreaterThan(0);
expect(r.blend.map((b) => b.archetype)).toContain('STRIKER');
});
it('a takedown-heavy submission threat profiles GRAPPLER-primary', () => {
const r = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, sapm: 2, strDef: 0.5, koRate: 0.1, subRate: 0.5, decRate: 0.4 });
expect(r.primary && r.primary.name).toBe('GRAPPLER');
});
it('a high-finish record profiles FINISHER via method rates', () => {
const r = svc.classify('mma', { koWins: 10, subWins: 5, decWins: 1, totalWins: 16 });
expect(r.blend.map((b) => b.archetype)).toContain('FINISHER');
});
it('THIN data yields NO fabricated style — empty blend, null primary (honest)', () => {
const r = svc.classify('mma', { record: '9-4-0' }); // no strike/td/method inputs
expect(r.blend).toEqual([]);
expect(r.primary).toBeNull();
expect(r.secondary).toBeNull();
});
it('an absent stat never scores an axis (Number(null) === 0 guard)', () => {
const r = svc.classify('mma', { slpm: null, tdAvg: undefined });
expect(r.blend).toEqual([]);
});
});
describe('styleMatchup — honest MODEL read, never a fabricated grade', () => {
const striker = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, tdAvg: 0.2, koRate: 0.6 });
const grappler = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, subRate: 0.5, decRate: 0.4 });
it('divergent styles produce a clear edge to one side (no confidence %)', () => {
const v = svc.styleMatchup(striker, grappler);
expect(v.verdict).toMatch(/EDGE$/);
expect(['a', 'b']).toContain(v.edgeSide);
expect(v).not.toHaveProperty('edge'); // no fabricated edge %
expect(v).not.toHaveProperty('confidence'); // no fabricated confidence
});
it('thin data on either side → INSUFFICIENT READ', () => {
expect(svc.styleMatchup(striker, svc.classify('mma', {})).verdict).toBe('INSUFFICIENT READ');
expect(svc.styleMatchup([], grappler).verdict).toBe('INSUFFICIENT READ');
});
it('two near-identical style blends → STYLES EVEN, no edge side', () => {
const v = svc.styleMatchup(striker, striker);
expect(v.verdict).toBe('STYLES EVEN');
expect(v.edgeSide).toBeNull();
});
it('accepts raw blend arrays as well as classify() results', () => {
const v = svc.styleMatchup(striker.blend, grappler.blend);
expect(v.verdict).toMatch(/EDGE$|EVEN|INSUFFICIENT/);
});
});
+50
View File
@@ -0,0 +1,50 @@
// Wave 6 — FightCard honesty locks (source-grep, same discipline as
// colorContract.test.js). The card must: self-hide on a non-two-fighter bout,
// render tale-of-the-tape as MONO data, label the verdict a MODEL read, and
// show method/round/KO as honest "data-limited" — never a fabricated grade.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('FightCard.tsx — honest v1 tale-of-the-tape', () => {
const src = read('components/vyndr/FightCard.tsx');
it('self-hides (returns null) when there arent two named fighters', () => {
expect(src).toMatch(/fighters\.length < 2/);
expect(src).toMatch(/return null/);
});
it('data rows are MONO, and no glitch CLASS is applied to any element (data never glitches)', () => {
expect(src).toContain('className="mono"');
// No glitch animation class on any element (comments about "never glitches" are fine).
expect(src).not.toMatch(/className=["'`][^"'`]*glitch/);
});
it('method / round / KO cells are shown as honest "data-limited", not fabricated', () => {
expect(src).toMatch(/dataLimited/);
expect(src).toContain('data-limited');
expect(src).toMatch(/METHOD/);
});
it('the verdict is explicitly labeled a MODEL read, not a settled grade', () => {
expect(src).toContain('MODEL READ');
expect(src).toContain('INSUFFICIENT READ');
});
it('uses a monogram (no fighter photo / likeness)', () => {
expect(src).toContain('Monogram');
expect(src).not.toMatch(/headshot|espncdn.*headshots|<img/i);
});
it('renders the archetype chip through the shared ArchetypeBadge with sport="mma"', () => {
expect(src).toContain('ArchetypeBadge');
expect(src).toMatch(/sport="mma"/);
});
it('absent odds render as a dash, never a fabricated number', () => {
expect(src).toMatch(/const DASH = '—'/);
expect(src).toMatch(/Number\.isFinite/);
});
});
+6 -2
View File
@@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () =>
expect(pricing).not.toContain("originalPrice: '$49.99'"); expect(pricing).not.toContain("originalPrice: '$49.99'");
}); });
test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => { test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => {
expect(page).toContain('import DeskShowcase'); expect(page).toContain('import DeskShowcase');
expect(page).toContain('<DeskShowcase'); expect(page).toContain('<DeskShowcase');
expect(showcase).toContain('$1M terminal'); // Wave 1 — the headline SHOWS what Desk does instead of claiming a dollar
// figure (VYNDR voice: understated, no hype, no "$1M"/"terminal"-as-brag).
expect(showcase).toContain('Every grade, every alt line, live.');
expect(showcase).not.toContain('$1M');
expect(showcase).not.toContain('1M terminal');
expect(showcase).toContain('$44.99'); // the conversion hook figure expect(showcase).toContain('$44.99'); // the conversion hook figure
expect(showcase).toContain('$34.99'); // the founder price expect(showcase).toContain('$34.99'); // the founder price
// real feature visuals fill the right half (kills the dead half, #8) // real feature visuals fill the right half (kills the dead half, #8)
+19
View File
@@ -18,6 +18,25 @@ describe('resolveTeam — abbr / full name / nickname / alias', () => {
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ'); expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS'); expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
}); });
test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => {
// NBA abbrs the ESPN schedule emits that used to fall to a monogram
expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs
expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks
expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards
expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets
// WNBA
expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun
expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics
});
test('global alias never shadows a real same-abbr team in another sport', () => {
// WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the
// NBA/WNBA WSH→WAS alias.
expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals');
// NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias.
expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty');
// NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed.
expect(resolveTeam('NY', 'mlb')).toBeNull();
});
test('unknown team → null (never a fake)', () => { test('unknown team → null (never a fake)', () => {
expect(resolveTeam('ZZZ', 'mlb')).toBeNull(); expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
expect(resolveTeam('', 'mlb')).toBeNull(); expect(resolveTeam('', 'mlb')).toBeNull();
+17 -1
View File
@@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => {
expect(r.found).toBe(true); expect(r.found).toBe(true);
expect(r.team).toBe('DAL'); expect(r.team).toBe('DAL');
expect(r.classifierInput.ppg).toBe(33); expect(r.classifierInput.ppg).toBe(33);
// Wave 2A — the REAL ESPN athlete id is surfaced (headshot CDN), not discarded.
expect(r.espnId).toBe('123');
});
it('Wave 2A — a non-numeric uid degrades espnId to null (never fabricated)', async () => {
const http = {
get: async (url) => {
if (url.includes('/search')) return { data: { items: [{ uid: 's:40~l:46~a:999', displayName: 'X', team: {}, position: {} }] } };
return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 10 }] }] } } } };
},
};
const r = await espn.getSeasonAverages('X', 'nba', { http });
expect(r.found).toBe(true);
expect(r.espnId).toBeNull();
}); });
it('degrades to found:false when ESPN errors', async () => { it('degrades to found:false when ESPN errors', async () => {
@@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => {
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => { it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', { const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } }, nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } },
espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) }, espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 }, espnId: '3945274' }) },
}); });
expect(r.found).toBe(true); expect(r.found).toBe(true);
expect(r.team).toBe('DAL'); expect(r.team).toBe('DAL');
expect(r.classifierInput.ppg).toBe(33); expect(r.classifierInput.ppg).toBe(33);
// Wave 2A — espnId surfaces through resolvePlayerStats → the snapshot grade.
expect(r.espnId).toBe('3945274');
}); });
it('found:false when both sources are empty', async () => { it('found:false when both sources are empty', async () => {
+123
View File
@@ -0,0 +1,123 @@
// Wave 4B — LIVE GRADE-SHIFT timeline helper + component (source-asserted).
// Locks the color law (green toward / amber against / dim flat), the revision
// strike-through, and the <3-point self-hide.
const fs = require('fs');
const path = require('path');
const {
buildGradeTimeline,
classifyMove,
cleanHistory,
TOWARD_COLOR,
AGAINST_COLOR,
FLAT_COLOR,
MIN_POINTS,
} = require('../../web/src/lib/gradeShift');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
describe('gradeShift helper — color law', () => {
it('color tokens are green / amber / dim (never red)', () => {
expect(TOWARD_COLOR).toBe('var(--g-a)');
expect(AGAINST_COLOR).toBe('var(--amber)');
expect(FLAT_COLOR).toBe('var(--text-1)');
for (const c of [TOWARD_COLOR, AGAINST_COLOR, FLAT_COLOR]) {
expect(c).not.toBe('var(--miss)');
}
});
it('OVER: line up = TOWARD (green), line down = AGAINST (amber), no move = FLAT (dim)', () => {
expect(classifyMove(0.5, 'Over')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
expect(classifyMove(-0.5, 'Over')).toEqual({ dir: 'against', color: AGAINST_COLOR });
expect(classifyMove(0, 'Over')).toEqual({ dir: 'flat', color: FLAT_COLOR });
});
it('UNDER inverts the sign: line down = TOWARD (green), line up = AGAINST (amber)', () => {
expect(classifyMove(-0.5, 'Under')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
expect(classifyMove(0.5, 'Under')).toEqual({ dir: 'against', color: AGAINST_COLOR });
});
});
describe('gradeShift helper — timeline assembly', () => {
it('self-hides below 3 real captured points', () => {
expect(buildGradeTimeline({ history: null }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }] }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 5.5 }] }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }] }).show).toBe(true);
});
it('drops non-numeric points (Number(null)===0 guard)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: null }, { t: '3', line: 6.5 }] });
expect(tl.points.length).toBe(2); // the null point is dropped, not coerced to 0
expect(cleanHistory([{ line: null }, { line: 3 }])).toHaveLength(1);
});
it('net move on an OVER that rose reads TOWARD (green)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }], side: 'Over' });
expect(tl.net.dir).toBe('toward');
expect(tl.net.color).toBe(TOWARD_COLOR);
expect(tl.firstLine).toBe(5.5);
expect(tl.lastLine).toBe(6.5);
});
it('net move on an OVER that fell reads AGAINST (amber)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 6.5 }, { t: '2', line: 6 }, { t: '3', line: 5.5 }], side: 'Over' });
expect(tl.net.dir).toBe('against');
expect(tl.net.color).toBe(AGAINST_COLOR);
});
it('a revision surfaces the ORIGINAL grade struck (from) alongside the new (to)', () => {
const tl = buildGradeTimeline({
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
grade: 'B',
revisedFrom: 'A',
});
expect(tl.revision).toEqual({ from: 'A', to: 'B' });
});
it('no revision when there was no prior grade or it is unchanged', () => {
const base = [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }];
expect(buildGradeTimeline({ history: base, grade: 'A' }).revision).toBeNull();
expect(buildGradeTimeline({ history: base, grade: 'A', revisedFrom: 'A' }).revision).toBeNull();
});
it('accepts the snapshot field name revised_from_grade too', () => {
const tl = buildGradeTimeline({
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
grade: 'C',
revised_from_grade: 'B',
});
expect(tl.revision).toEqual({ from: 'B', to: 'C' });
});
it('MIN_POINTS matches the LineSparkline floor of 3', () => {
expect(MIN_POINTS).toBe(3);
});
});
describe('GradeShift component', () => {
const src = read('components/vyndr/GradeShift.tsx');
it('self-hides on !tl.show (returns null)', () => {
expect(src).toContain('if (!tl.show) return null');
});
it('renders the original grade struck-through when revised', () => {
expect(src).toContain('tl.revision');
expect(src).toContain('line-through');
expect(src).toContain('tl.revision.from');
expect(src).toContain('tl.revision.to');
});
it('is a data surface — mono, no glitch classes', () => {
expect(src).toContain('mono');
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
});
});
describe('GradeResultCard mounts the timeline', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
it('imports and renders GradeShift with the optional history fields', () => {
expect(src).toContain("import GradeShift from '@/components/vyndr/GradeShift'");
expect(src).toContain('<GradeShift');
expect(src).toContain('history?: Array<{ t: string; line: number }> | null');
expect(src).toContain('revisedFrom');
});
});
+110
View File
@@ -0,0 +1,110 @@
// Wave 2A (WIRING & DATA TRAIN, Step 2) — the headshot id thread.
//
// Doctrine: a REAL athlete photo where an id resolves; a team-colored monogram
// (NEVER a gray silhouette, NEVER a broken image) where it can't. The id is
// NEVER fabricated — it rides free on the snapshot's per-player stats resolve.
//
// This suite locks the four links of the chain:
// (a) getHeadshotUrl builds the right per-league CDN URL from a known id
// (b) an MLB grade with a resolved MLBAM id → playerId on the strip
// (c) an NBA/WNBA grade with a resolved ESPN id → espnId on the strip
// (d) a grade with NO id → strip carries no id → PlayerAvatar falls to a
// monogram (the null path). Absent beats fabricated.
// The PURE URL core is CommonJS (the .ts re-exports it verbatim); jest can't
// transform the .ts, so we require the same single source of truth here.
const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl');
const adapter = require('../../web/src/lib/slateAdapter');
describe('(a) getHeadshotUrl — per-league CDN URL from a real id', () => {
it('MLB → img.mlbstatic.com via the MLBAM people id', () => {
// Aaron Judge = MLBAM 592450.
const url = getHeadshotUrl({ sport: 'mlb', playerId: 592450 });
expect(url).toBe(
'https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/592450/headshot/67/current',
);
});
it('NBA → a.espncdn headshot from the ESPN athlete id (espnId, no playerId)', () => {
const url = getHeadshotUrl({ sport: 'nba', espnId: 3945274 });
expect(url).toBe(
'https://a.espncdn.com/combiner/i?img=/i/headshots/nba/players/full/3945274.png&w=130&h=95',
);
});
it('WNBA → a.espncdn headshot from the ESPN athlete id', () => {
const url = getHeadshotUrl({ sport: 'wnba', espnId: 4066533 });
expect(url).toBe(
'https://a.espncdn.com/combiner/i?img=/i/headshots/wnba/players/full/4066533.png&w=130&h=95',
);
});
it('dormant NFL/NHL leagues now resolve an ESPN headshot path (cheap correctness)', () => {
expect(getHeadshotUrl({ sport: 'nfl', espnId: 3139477 })).toContain('/headshots/nfl/players/full/3139477.png');
expect(getHeadshotUrl({ sport: 'nhl', espnId: 3024816 })).toContain('/headshots/nhl/players/full/3024816.png');
});
it('no id at all → the neutral silhouette sentinel (component swaps to monogram)', () => {
expect(getHeadshotUrl({ sport: 'mlb' })).toBe('/images/player-silhouette.svg');
expect(getHeadshotUrl({ sport: 'soccer', playerId: 123 })).toBe('/images/player-silhouette.svg');
});
});
describe('(b) MLB grade → playerId threads onto the strip', () => {
it('carries the MLBAM playerId from the enriched grade to the strip prop group', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5, home_team: 'NYY', away_team: 'BOS' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Aaron Judge', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
playerId: 592450, team: 'NYY',
gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].playerId).toBe(592450);
expect(strips[0].espnId).toBeUndefined();
// And the id builds the real MLB headshot.
expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId })).toContain('/people/592450/headshot');
});
});
describe('(c) NBA/WNBA grade → espnId threads onto the strip', () => {
it('carries the ESPN espnId from the enriched grade to the strip prop group', () => {
const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'B+',
espnId: 4433403, team: 'IND',
gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].espnId).toBe(4433403);
expect(strips[0].playerId).toBeUndefined();
expect(getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId })).toContain('/players/full/4433403.png');
});
});
describe('(d) no resolved id → monogram path (never a fabricated face)', () => {
it('a grade with no id → strip has neither playerId nor espnId', () => {
const props = [{ player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, home_team: 'NYY', away_team: 'BOS' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'C',
team: 'NYY',
gradedAt: { line: 0.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips[0].playerId).toBeUndefined();
expect(strips[0].espnId).toBeUndefined();
// PlayerAvatar renders `url = (playerId!=null || espnId!=null) ? … : null`,
// so an absent id yields a null url → the branded monogram. Prove the
// resolver returns the silhouette sentinel (which the component swaps out)
// rather than a fabricated CDN URL when no id is present.
expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId, espnId: strips[0].espnId }))
.toBe('/images/player-silhouette.svg');
});
});
+170
View File
@@ -0,0 +1,170 @@
// Wave 5A (D2) — the HOUSE/model /u profile. The reserved house handle
// (`vyndr`) resolves to the PUBLIC model record (user_id = NULL) WITHOUT a
// public_profiles row — the partner-pitch weapon. Every OTHER handle keeps the
// private-by-default, no-existence-leak 404 (a byte-identical body for unknown
// AND unpublished). Supabase + auth mocked (profilesRoutes.test.js pattern) —
// no network, no live Supabase.
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
jest.mock('../../src/middleware/auth', () => ({
requireAuth: (req, res, next) => {
if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' });
req.user = { id: 'u1', tier: 'analyst' };
return next();
},
}));
const mockState = {
profileRow: null,
ledgerRows: [],
filters: [], // [table, filters[]]
};
function mockChain(table) {
const b = { _filters: [] };
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
b.select = () => b;
b.eq = rec('eq');
b.is = rec('is');
b.not = rec('not');
b.gte = rec('gte');
b.order = () => b;
b.maybeSingle = () => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.profileRow, error: null });
};
b.limit = () => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 });
};
b.then = (resolve, reject) => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 }).then(resolve, reject);
};
return b;
}
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ from: (table) => mockChain(table) }),
}));
process.env.SUPABASE_URL = 'https://test.supabase.co';
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key';
function mountApp() {
// resetModules forces profiles.js to re-read process.env.HOUSE_HANDLE (jest
// ignores require.cache deletes). The jest.mock factories re-apply.
jest.resetModules();
const routes = require('../../src/routes/profiles');
const app = express();
app.use(express.json());
app.use('/api/profiles', routes);
return app;
}
beforeEach(() => {
mockState.profileRow = null;
mockState.ledgerRows.length = 0;
mockState.filters.length = 0;
});
describe('GET /api/profiles/vyndr — the HOUSE/model profile', () => {
test('resolves the public user_id=NULL aggregate with NO public_profiles row', async () => {
// Note: profileRow stays null — the house handle must NOT need a claim.
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A', clv_result: 'beat', clv: 0.5 });
const res = await request(mountApp()).get('/api/profiles/vyndr');
expect(res.status).toBe(200);
expect(res.body.handle).toBe('vyndr');
expect(res.body.house).toBe(true);
expect(res.body.label).toBe('VYNDR MODEL · PUBLIC RECORD');
expect(res.body.min_sample).toBe(20);
expect(res.body.aggregate).toBeTruthy();
// Wave 3 per-tier calibration rides along inside the aggregate.
expect(res.body.aggregate.by_tier).toBeDefined();
// n<20 gate honored: hit_pct is present but null (not a small-sample %).
expect(res.body.aggregate.hit_pct).toBeNull();
expect(Array.isArray(res.body.entries)).toBe(true);
// The public_profiles table was NEVER queried (handle is reserved).
expect(mockState.filters.filter(([t]) => t === 'public_profiles')).toHaveLength(0);
// Every ledger query is the PUBLIC record (user_id IS NULL), never a user.
const ledgerQueries = mockState.filters.filter(([t]) => t === 'ledger_entries');
expect(ledgerQueries.length).toBeGreaterThan(0);
for (const [, filters] of ledgerQueries) {
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id' && f[2] === null)).toBe(true);
expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false);
}
// The entries list is settled rows only.
const entriesQ = ledgerQueries.find(([, f]) => f.some((x) => x[0] === 'not' && x[1] === 'outcome'));
expect(entriesQ).toBeTruthy();
});
test('honors an env override for the house handle', async () => {
const prev = process.env.HOUSE_HANDLE;
process.env.HOUSE_HANDLE = 'house';
try {
const res = await request(mountApp()).get('/api/profiles/house');
expect(res.status).toBe(200);
expect(res.body.house).toBe(true);
// The default handle is no longer reserved → falls through to 404.
const other = await request(mountApp()).get('/api/profiles/vyndr');
expect(other.status).toBe(404);
} finally {
if (prev === undefined) delete process.env.HOUSE_HANDLE; else process.env.HOUSE_HANDLE = prev;
}
});
});
describe('privacy preserved — the house handle is the ONLY special case', () => {
test('NO EXISTENCE LEAK — unknown and unpublished USER handles are byte-identical 404s', async () => {
mockState.profileRow = null;
const unknown = await request(mountApp()).get('/api/profiles/ghost_handle');
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
const unpublished = await request(mountApp()).get('/api/profiles/private_kev');
expect(unknown.status).toBe(404);
expect(unpublished.status).toBe(404);
expect(unknown.body).toEqual(unpublished.body);
expect(unknown.body).toEqual({ error: 'Profile not found' });
});
test('an unpublished USER handle still leaks NO ledger data', async () => {
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' });
const res = await request(mountApp()).get('/api/profiles/private_kev');
expect(res.status).toBe(404);
expect(res.body.entries).toBeUndefined();
expect(mockState.filters.filter(([t]) => t === 'ledger_entries')).toHaveLength(0);
});
});
describe('/u/[handle] renders the house label + per-tier calibration', () => {
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
it('PublicProfile shows the house label off data.house + keeps TierRecord', () => {
const src = read('app/u/[handle]/PublicProfile.tsx');
expect(src).toContain('VYNDR MODEL · PUBLIC RECORD');
expect(src).toMatch(/data\.house/);
expect(src).toContain('TierRecord');
});
it('has a portrait 1080x1350 share crop route', () => {
const src = read('app/u/[handle]/portrait/route.tsx');
expect(src).toContain('1080');
expect(src).toContain('1350');
expect(src).not.toContain("runtime = 'edge'");
});
it('is discoverable — a link to /u/vyndr on the ledger + landing record surfaces', () => {
expect(read('app/ledger/page.tsx')).toContain('/u/vyndr');
expect(read('app/page.tsx')).toContain('/u/vyndr');
});
});
+19
View File
@@ -251,6 +251,25 @@ describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
expect(agg.by_tier.B.settled).toBe(3); expect(agg.by_tier.B.settled).toBe(3);
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
}); });
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
// A+ is its own bucket — never merged into the first-letter A bucket.
expect(agg.by_tier['A+'].settled).toBe(5);
expect(agg.by_tier['A+'].hits).toBe(5);
// A + A- fold together by first letter (but NOT A+).
expect(agg.by_tier.A.settled).toBe(6);
expect(agg.by_tier.A.hits).toBe(4);
expect(agg.by_tier.A.misses).toBe(2);
});
}); });
// Session 61 — the odds index prefers a fully-priced book row (the null // Session 61 — the odds index prefers a fully-priced book row (the null
+115
View File
@@ -0,0 +1,115 @@
// Wave 4A — MARKET-BREADTH / CONSENSUS strip (Step 4). Makes the DeskShowcase
// "consensus vs model" claim REAL: the consensus is the MEDIAN book line across
// the prop's per-book rows; the model's position is model_value vs that
// consensus, signed by the graded side. Doctrine: never fabricate a consensus —
// <2 distinct books → null (absent beats invented); a non-numeric line is
// ignored, never coerced to 0 (the Number(null)===0 trap).
const { computeBreadth, collectBreadth, median } = require('../../web/src/lib/marketBreadth');
describe('median', () => {
it('odd length → middle', () => { expect(median([2.5, 1.5, 3.5])).toBe(2.5); });
it('even length → mean of the two middles', () => { expect(median([1.5, 2.5])).toBe(2); });
it('ignores non-finite, empty → null', () => {
expect(median([NaN, 1.5, 2.5, null])).toBe(2);
expect(median([])).toBeNull();
expect(median(null)).toBeNull();
});
});
describe('computeBreadth — median consensus from ≥2 books', () => {
const books = [
{ book: 'draftkings', line: 2.5, over_odds: -115 },
{ book: 'fanduel', line: 2.5, over_odds: -110 },
{ book: 'betmgm', line: 1.5, over_odds: -120 },
];
it('consensus is the median book LINE; model above → OVER edge (positive, green)', () => {
const b = computeBreadth(books, 3.0, 'over');
expect(b.consensus).toBe(2.5);
expect(b.bookCount).toBe(3);
expect(b.model).toBe(3.0);
expect(b.delta).toBeCloseTo(0.5);
expect(b.signedEdge).toBeCloseTo(0.5); // model projects ABOVE market → supports OVER
expect(b.position).toBe('above');
expect(b.side).toBe('over');
});
it('UNDER read: model BELOW consensus is the edge (signed positive)', () => {
const b = computeBreadth(books, 2.0, 'under');
expect(b.consensus).toBe(2.5);
expect(b.delta).toBeCloseTo(-0.5); // raw model - consensus stays signed to the market
expect(b.signedEdge).toBeCloseTo(0.5); // consensus - model, favors UNDER
expect(b.position).toBe('below');
expect(b.side).toBe('under');
});
it('model behind the market on an OVER → negative signed edge (amber/red)', () => {
const b = computeBreadth(books, 2.0, 'over');
expect(b.signedEdge).toBeCloseTo(-0.5);
expect(b.position).toBe('below');
});
it('<2 DISTINCT books → null (never fabricate a consensus)', () => {
expect(computeBreadth([{ book: 'dk', line: 2.5 }], 3, 'over')).toBeNull();
// same book twice is still one opinion → not a consensus
expect(computeBreadth([{ book: 'dk', line: 2.5 }, { book: 'dk', line: 1.5 }], 3, 'over')).toBeNull();
expect(computeBreadth([], 3)).toBeNull();
expect(computeBreadth(null, 3)).toBeNull();
});
it('absent model → real consensus present, comparison null (no invented model)', () => {
const b = computeBreadth(books, null, 'over');
expect(b.consensus).toBe(2.5);
expect(b.bookCount).toBe(3);
expect(b.model).toBeNull();
expect(b.delta).toBeNull();
expect(b.signedEdge).toBeNull();
expect(b.position).toBeNull();
});
it('a non-numeric book line is IGNORED, not coerced to 0', () => {
const mixed = [{ book: 'dk', line: 2.5 }, { book: 'fd', line: null }, { book: 'mgm', line: 2.5 }];
const b = computeBreadth(mixed, 2.5, 'over');
expect(b.bookCount).toBe(2);
expect(b.consensus).toBe(2.5);
expect(b.position).toBe('inline'); // model == consensus
expect(b.signedEdge).toBe(0);
});
});
describe('collectBreadth — ranked list, self-hiding', () => {
it('drops <2-book props and ranks by |signedEdge| desc', () => {
const items = [
{ player: 'A', stat: 'hits', side: 'over', line: 1.5, modelValue: 2.0, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }] },
{ player: 'B', stat: 'tb', side: 'over', line: 2.5, modelValue: 2.6, books: [{ book: 'dk', line: 2.5 }, { book: 'fd', line: 2.5 }] },
{ player: 'C', stat: 'ks', side: 'over', line: 5.5, modelValue: 6, books: [{ book: 'dk', line: 5.5 }] }, // 1 book → dropped
];
const out = collectBreadth(items, 6);
expect(out).toHaveLength(2);
expect(out[0].player).toBe('A'); // |0.5| edge beats |0.1|
expect(out[1].player).toBe('B');
});
it('empty when nothing qualifies (component self-hides)', () => {
expect(collectBreadth([{ player: 'C', stat: 'ks', side: 'over', books: [{ book: 'dk', line: 5.5 }] }], 6)).toEqual([]);
expect(collectBreadth(null)).toEqual([]);
expect(collectBreadth([])).toEqual([]);
});
});
describe('source: MarketBreadth component self-hides + colors by sign', () => {
const fs = require('fs');
const path = require('path');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
it('MarketBreadth.tsx colors the edge via the color contract (edgeColor), not raw green', () => {
const src = read('components/vyndr/MarketBreadth.tsx');
expect(src).toContain('edgeColor');
});
it('MarketBreadth.tsx self-hides when there is nothing to show', () => {
const src = read('components/vyndr/MarketBreadth.tsx');
expect(src).toMatch(/return null/);
});
});
+141
View File
@@ -0,0 +1,141 @@
// Wave 1 trust bug — MLB namesake collision. Two different players can share an
// EXACT nameKey ("James Wood": the Nationals star + a Cubs-affiliate namesake).
// The old `.find` took the first → wrong currentTeam → wrong opponents → "built
// vs AL East" fabrication in streaks/rosterlogs. Doctrine: never guess among
// namesakes — resolve only with a confident team hint, else refuse (null).
//
// Hermetic: `searchPlayer` accepts injected `people`/`teams` fixtures — no
// network, no redis.
const mlb = require('../../src/services/adapters/mlbStatsAdapter');
const { teamRecordMatchesHint, disambiguateByHint, canonAbbr } = mlb.__internals;
// statsapi-shaped fixtures.
const JUDGE = {
id: 592450, fullName: 'Aaron Judge',
currentTeam: { id: 147, name: 'New York Yankees' },
primaryPosition: { abbreviation: 'RF' },
};
const WOOD_NATIONALS = {
id: 691026, fullName: 'James Wood',
currentTeam: { id: 120, name: 'Washington Nationals' },
primaryPosition: { abbreviation: 'LF' },
};
const WOOD_CUBS = {
id: 999999, fullName: 'James Wood',
currentTeam: { id: 112, name: 'Chicago Cubs' },
primaryPosition: { abbreviation: 'P' },
};
const SOTO_METS = {
id: 665742, fullName: 'Juan Soto',
currentTeam: { id: 121, name: 'New York Mets' },
primaryPosition: { abbreviation: 'RF' },
};
const TEAMS = [
{ id: 147, abbr: 'NYY', name: 'New York Yankees' },
{ id: 120, abbr: 'WSH', name: 'Washington Nationals' },
{ id: 112, abbr: 'CHC', name: 'Chicago Cubs' },
{ id: 146, abbr: 'MIA', name: 'Miami Marlins' },
{ id: 121, abbr: 'NYM', name: 'New York Mets' },
{ id: 119, abbr: 'LAD', name: 'Los Angeles Dodgers' },
{ id: 135, abbr: 'SD', name: 'San Diego Padres' },
];
describe('searchPlayer — namesake disambiguation (never guess)', () => {
test('(a) a single unambiguous name resolves (Aaron Judge → Yankees)', async () => {
const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS });
expect(r).not.toBeNull();
expect(r.id).toBe(592450);
expect(r.team).toBe('New York Yankees');
});
test('(b) two "James Wood" with NO hint → null (NEVER the Cubs one)', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS });
expect(r).toBeNull(); // refuse — honest absent beats wrong
});
test('(c) two "James Wood" + a Nationals-game hint → the Nationals Wood', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first — the old bug picked this
teams: TEAMS,
teamHint: ['WSH', 'MIA'], // Nationals @ Marlins
});
expect(r).not.toBeNull();
expect(r.id).toBe(691026);
expect(r.team).toBe('Washington Nationals');
expect(r.team).not.toBe('Chicago Cubs');
});
test('(c2) a full-team-name hint disambiguates too', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_CUBS, WOOD_NATIONALS],
teams: TEAMS,
teamHint: ['Washington Nationals', 'Miami Marlins'],
});
expect(r.id).toBe(691026);
});
test('(c3) two namesakes + a hint matching NEITHER → null (still refuse)', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_NATIONALS, WOOD_CUBS],
teams: TEAMS,
teamHint: ['LAD', 'SD'],
});
expect(r).toBeNull();
});
});
// JOIN INVARIANT (streaks/rosterlogs) — a resolved player's team must be a
// participant of the prop's game; on mismatch the team is DROPPED, never a
// foreign tag. This is the mechanism snapshotService relies on before it writes
// teamByPlayer + rosterlogs opponents.
describe('searchPlayer — team join invariant (drop, never tag foreign)', () => {
test('a single player whose team is NOT in the hinted game → team dropped to null', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, {
people: [SOTO_METS],
teams: TEAMS,
teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither → cannot confirm join
});
expect(r).not.toBeNull(); // still the right player by name
expect(r.id).toBe(665742);
expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game
expect(r.teamId).toBeNull();
});
test('a single player whose team IS in the hinted game keeps its team', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, {
people: [SOTO_METS],
teams: TEAMS,
teamHint: ['NYM', 'LAD'], // Mets @ Dodgers → confirmed
});
expect(r.team).toBe('New York Mets');
});
test('with NO hint, a single player keeps its authoritative team', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS });
expect(r.team).toBe('New York Mets');
});
});
describe('pure helpers — abbr reconciliation + hint matching', () => {
test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => {
expect(canonAbbr('AZ')).toBe(canonAbbr('ARI'));
expect(canonAbbr('CHW')).toBe(canonAbbr('CWS'));
expect(canonAbbr('nyy')).toBe('NYY');
});
test('teamRecordMatchesHint matches by abbr via the teams list', () => {
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true);
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false);
});
test('teamRecordMatchesHint matches by partial name', () => {
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true);
});
test('disambiguateByHint refuses when 2 candidates share the hinted team', () => {
const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }];
expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull();
});
});
+10
View File
@@ -244,4 +244,14 @@ describe('oddsNormalizer', () => {
expect(result[0].away_team).toBe('PHX'); expect(result[0].away_team).toBe('PHX');
}); });
}); });
// Wave 6 — combat (MMA) game-level markets. Without these MARKET_MAP keys,
// combat moneyline/round-total odds would silently normalize to zero (same
// silent-failure class as the MLB/NHL gaps closed earlier).
describe('MMA / combat market keys (Wave 6)', () => {
it('maps h2h → moneyline and totals → round_total', () => {
expect(MARKET_MAP.h2h).toBe('moneyline');
expect(MARKET_MAP.totals).toBe('round_total');
});
});
}); });
+95
View File
@@ -0,0 +1,95 @@
// Wave 4A — OUTLOOK MODE (never-empty slate grid, Step 3).
// The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no
// live games it falls back to REAL always-available data: yesterday's PROVEN
// A-tier receipts + tomorrow's date-pinned schedule preview. A network
// fetchError stays an ERROR state (a fetch failure is never a fake outlook).
const { buildOutlook, mapTomorrowPreview } = require('../../web/src/lib/outlook');
describe('mapTomorrowPreview — real schedule → preview rows', () => {
const games = [
{ id: 'g1', awayTeam: { name: 'Yankees' }, homeTeam: { name: 'Red Sox' }, gameTime: '2026-07-14T23:00:00Z', status: 'pre', sport: 'mlb' },
{ id: 'g2', awayTeam: { abbreviation: 'LAD' }, homeTeam: { abbreviation: 'SF' }, status: 'in' }, // live → dropped (a preview is upcoming only)
{ id: 'g3', awayTeam: {}, homeTeam: { name: 'X' } }, // missing away team → dropped (never fabricate a matchup)
{ id: 'g4', awayTeam: { name: 'Cubs' }, homeTeam: { name: 'Cards' }, status: 'post' }, // finished → dropped
];
it('maps upcoming games only, dropping live / finished / incomplete', () => {
const out = mapTomorrowPreview(games, 8);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ id: 'g1', away: 'Yankees', home: 'Red Sox', sport: 'MLB' });
});
it('caps at the limit and never fabricates a matchup from bad input', () => {
const many = Array.from({ length: 10 }, (_, i) => ({ id: `g${i}`, awayTeam: { name: `A${i}` }, homeTeam: { name: `H${i}` }, status: 'pre' }));
expect(mapTomorrowPreview(many, 3)).toHaveLength(3);
expect(mapTomorrowPreview(null, 5)).toEqual([]);
expect(mapTomorrowPreview(undefined, 5)).toEqual([]);
});
it('accepts the flat {away,home} shape too (dashboard schedule mapping)', () => {
const out = mapTomorrowPreview([{ id: 'z', away: 'Mets', home: 'Phillies', start_time: '2026-07-14T18:00:00Z' }], 8);
expect(out[0]).toMatchObject({ away: 'Mets', home: 'Phillies' });
});
});
describe('buildOutlook — never-empty selection', () => {
const settled = [
{ player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, sport: 'mlb' },
{ player_name: 'Low Grade', stat: 'hits', line: 0.5, side: 'over', grade: 'C', outcome: 'hit' }, // not A-tier → not a receipt
{ player_name: 'The Miss', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss' }, // a miss never becomes proof
];
const tomorrow = [{ id: 't1', awayTeam: { name: 'Mets' }, homeTeam: { name: 'Braves' }, status: 'pre', sport: 'mlb' }];
it('a network fetchError stays an ERROR state — never a fabricated outlook', () => {
expect(buildOutlook({ gamesCount: 0, fetchError: 'No games available right now.', settledRows: settled, tomorrow })).toEqual({ mode: 'error' });
});
it('live games on the board → live mode (outlook not shown)', () => {
expect(buildOutlook({ gamesCount: 3, settledRows: settled, tomorrow })).toEqual({ mode: 'live' });
});
it('no games → outlook carrying PROVEN receipts AND tomorrow preview (never blank)', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow });
expect(o.mode).toBe('outlook');
expect(o.receipts).toHaveLength(1);
expect(o.receipts[0].player).toBe('Aaron Judge');
expect(o.receipts[0].outcome).toBe('hit');
expect(o.tomorrow).toHaveLength(1);
expect(o.tomorrow[0].home).toBe('Braves');
expect(o.hasContent).toBe(true);
});
it('no games, no proof, no schedule → STILL an outlook surface (the header carries it), never error / never blank', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: [], tomorrow: [] });
expect(o.mode).toBe('outlook');
expect(o.receipts).toEqual([]);
expect(o.tomorrow).toEqual([]);
expect(o.hasContent).toBe(false);
});
it('receipts alone (no tomorrow schedule) still fills the grid', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow: [] });
expect(o.mode).toBe('outlook');
expect(o.receipts).toHaveLength(1);
expect(o.tomorrow).toEqual([]);
expect(o.hasContent).toBe(true);
});
});
// The dead-end CTA cards must be gone: the grid now renders an Outlook surface.
describe('source: the slate + dashboard render the Outlook surface (not a NO-SLATE dead-end)', () => {
const fs = require('fs');
const path = require('path');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
it('Slate.tsx wires buildOutlook / the Outlook surface into the empty branch', () => {
const src = read('components/Slate.tsx');
expect(src).toMatch(/OutlookSurface|buildOutlook/);
});
it('dashboard renders the Outlook surface instead of the "NO SLATE" CTA', () => {
const src = read('app/dashboard/page.tsx');
expect(src).toMatch(/OutlookSurface|buildOutlook|mapTomorrowPreview/);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Wave 4B — Parlay Lab page (/parlay). Source-asserts the dedicated builder:
// an independent (non-slate) leg source, useParlay/legKey wiring, the
// correlation-flag caution, and the tier-aware leg cap.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('Parlay Lab page exists at /parlay', () => {
it('the route file is present', () => {
expect(fs.existsSync(path.join(WEB, 'app', 'parlay', 'page.tsx'))).toBe(true);
});
});
describe('/parlay — independent leg source (not the live slate)', () => {
const src = read('app/parlay/page.tsx');
it('browses pre-graded props from /api/snapshot/:sport', () => {
expect(src).toContain('/api/snapshot/');
});
it('resolves players via /api/players/search (per sport)', () => {
expect(src).toContain('/api/players/search');
});
it('does NOT depend on the live Slate component', () => {
expect(src).not.toContain("from '@/components/Slate'");
expect(src).not.toContain('slateAdapter');
});
it('offers the three parlay sports', () => {
for (const s of ['MLB', 'NBA', 'WNBA']) expect(src).toContain(s);
});
});
describe('/parlay — assembles a slip via useParlay + legKey', () => {
const src = read('app/parlay/page.tsx');
it('imports useParlay and legKey', () => {
expect(src).toContain('useParlay');
expect(src).toContain('legKey');
});
it('adds legs through addLeg and dedupes/toggles by leg key', () => {
expect(src).toContain('addLeg(');
expect(src).toContain('hasLeg(');
expect(src).toContain('removeLeg(');
});
it('renders the PARLAY SLIP with combined grade + correlation + payout from context', () => {
expect(src).toContain('PARLAY SLIP');
expect(src).toContain('combined');
expect(src).toContain('correlation');
expect(src).toContain('payout');
});
});
describe('/parlay — correlation-flag caution', () => {
const src = read('app/parlay/page.tsx');
it('surfaces parlayService correlation warning as a caution flag', () => {
expect(src).toContain('correlation?.warning');
expect(src).toContain('CORRELATION FLAG');
});
});
describe('/parlay — tier-aware leg cap + free upsell', () => {
const src = read('app/parlay/page.tsx');
it('drives the leg cap from the user tier (free 2 / analyst 4 / desk 6)', () => {
expect(src).toContain('useAuth');
expect(src).toContain('setMaxLegs');
expect(src).toContain('free: 2');
expect(src).toContain('desk: 6');
});
it('honors atCap and blurs the payout for free tier with the paywall upsell', () => {
expect(src).toContain('atCap');
expect(src).toContain('__goPaywall');
expect(src).toContain("blur(");
});
});
describe('/parlay is reachable (open route, not gated)', () => {
const routes = require('../../web/src/lib/routes.js');
it('is an OPEN route (free funnel, like scan/dashboard)', () => {
expect(routes.OPEN_ROUTES).toContain('/parlay');
expect(routes.isGatedRoute('/parlay')).toBe(false);
});
});
describe('legacy ParlayTray is retired', () => {
it('the dead component file is gone', () => {
expect(fs.existsSync(path.join(WEB, 'components', 'ParlayTray.tsx'))).toBe(false);
});
});
@@ -0,0 +1,67 @@
'use strict';
// Wave 5B — PitcherArsenal component source locks. The card is CONTEXT (the
// arsenal read), never a graded market value. Honesty invariants:
// • SELF-HIDES (returns null) when Savant has no arsenal — no empty shell.
// • ALL pitch data (velo / usage / whiff) is mono + tabular (brand rule).
// • A missing velo/whiff renders "—" (absent), NEVER 0.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('PitcherArsenal component', () => {
const src = read('components/vyndr/PitcherArsenal.tsx');
test('self-hides (returns null) when there are no pitches', () => {
expect(src).toMatch(/if\s*\(\s*pitches\.length\s*===\s*0\s*\)\s*return null/);
});
test('renders the arsenal strip headers (VELO / USE / WHIFF)', () => {
expect(src).toContain('VELO');
expect(src).toContain('USE');
expect(src).toContain('WHIFF');
expect(src).toContain('PITCH');
});
test('pitch data is mono + tabular (brand rule: data is mono)', () => {
expect(src).toContain('className="mono"');
expect(src).toContain('tabular-nums');
// the three data cells map the normalized fields
expect(src).toContain('fmtVelo(p.velo)');
expect(src).toContain('fmtPct(p.usagePct)');
expect(src).toContain('fmtPct(p.whiffPct)');
});
test('absent velo/whiff renders an em-dash, never 0 (absent beats zero)', () => {
// fmtVelo / fmtPct return '—' for null|undefined
expect(src).toMatch(/function fmtVelo[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
expect(src).toMatch(/function fmtPct[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
// no `?? 0` / `|| 0` coercion on the numeric fields
expect(src).not.toMatch(/velo\s*(\?\?|\|\|)\s*0/);
expect(src).not.toMatch(/whiffPct\s*(\?\?|\|\|)\s*0/);
});
test('cites Baseball Savant as the source (honest provenance)', () => {
expect(src).toMatch(/BASEBALL SAVANT/i);
});
test('ranks the strip by the normalized fields from the adapter contract', () => {
// consumes { found, pitches:[{ type, name, usagePct, velo, whiffPct }] }
expect(src).toContain('fetched.found');
expect(src).toContain('fetched.pitches');
});
test('is exported from the vyndr barrel', () => {
const barrel = read('components/vyndr/index.ts');
expect(barrel).toContain("export { default as PitcherArsenal }");
});
test('the arsenal proxy route exists (S25 rule) and forwards to the backend', () => {
const proxy = read('app/api/stats/pitcher/[name]/arsenal/route.ts');
expect(proxy).toContain('/api/stats/pitcher/');
expect(proxy).toContain('/arsenal');
expect(proxy).toContain('found: false'); // honest fallback, not an error card
});
});
+100
View File
@@ -0,0 +1,100 @@
'use strict';
const savant = require('../../src/services/adapters/savantAdapter');
// Fixture CSVs shaped like the real Baseball Savant leaderboard exports.
// NOTE the first header column is a QUOTED field containing a comma
// (`"last_name, first_name"`) — the parser must be quote-aware.
const STATS_CSV = [
'"last_name, first_name",player_id,team_name_alt,pitch_type,pitch_name,run_value_per_100,pitches,pitch_usage,pa,whiff_percent,k_percent',
'"Skenes, Paul",694973,PIT,FF,4-Seam Fastball,1.2,500,32.0,300,26.0,30.0',
'"Skenes, Paul",694973,PIT,SL,Slider,2.1,350,22.0,200,41.0,38.0',
'"Skenes, Paul",694973,PIT,FS,Splitter,1.8,300,24.0,180,38.0,",', // trailing malformed cell → whiff null-ish; still parses
'"Skenes, Paul",694973,PIT,CU,Curveball,0.5,120,14.0,90,33.0,20.0',
'"Other, Guy",111111,LAD,CH,Changeup,0.1,50,,40,,', // usage/whiff BLANK → must be null, never 0
].join('\n');
// WIDE velo leaderboard — one row per pitcher, `{abbr}_avg_speed` columns.
const VELO_CSV = [
'"last_name, first_name",pitcher,team,ff_avg_speed,sl_avg_speed,fs_avg_speed,cu_avg_speed',
'"Skenes, Paul",694973,PIT,99.1,87.0,94.2,82.5',
].join('\n');
describe('savantAdapter.getPitcherArsenal', () => {
test('normalizes a fake Savant CSV payload into a ranked arsenal (usage desc)', async () => {
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV, veloCsv: VELO_CSV });
expect(out.found).toBe(true);
expect(out.playerId).toBe(694973);
expect(out.source).toBe('baseball_savant');
expect(Array.isArray(out.pitches)).toBe(true);
// ranked by usage: FF(32) > FS(24) > SL(22) > CU(14)
expect(out.pitches.map((p) => p.type)).toEqual(['FF', 'FS', 'SL', 'CU']);
const ff = out.pitches[0];
expect(ff.usagePct).toBe(32.0);
expect(ff.velo).toBe(99.1); // merged from the WIDE velo CSV by pitch abbr
expect(ff.whiffPct).toBe(26.0);
const sl = out.pitches.find((p) => p.type === 'SL');
expect(sl.velo).toBe(87.0);
expect(sl.whiffPct).toBe(41.0);
});
test('missing velo is ABSENT (null), never 0 — velo CSV omitted entirely', async () => {
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV /* no veloCsv */ });
expect(out.found).toBe(true);
for (const p of out.pitches) expect(p.velo).toBeNull();
});
test('blank whiff/usage cells parse to null, not 0 (absent beats zero)', async () => {
const out = await savant.getPitcherArsenal(111111, { statsCsv: STATS_CSV });
expect(out.found).toBe(true);
expect(out.pitches).toHaveLength(1);
expect(out.pitches[0].usagePct).toBeNull();
expect(out.pitches[0].whiffPct).toBeNull();
expect(out.pitches[0].velo).toBeNull();
});
test('unrecognized shape → { found:false } (defensive parsing)', async () => {
const junk = 'totally,unrelated,columns\n1,2,3';
const out = await savant.getPitcherArsenal(694973, { statsCsv: junk });
expect(out.found).toBe(false);
const empty = await savant.getPitcherArsenal(694973, { statsCsv: '' });
expect(empty.found).toBe(false);
});
test('a pitcher with no rows in the feed → { found:false }', async () => {
const out = await savant.getPitcherArsenal(999999, { statsCsv: STATS_CSV });
expect(out.found).toBe(false);
});
test('resolves a NAME → id via injected resolver, then returns arsenal', async () => {
const out = await savant.getPitcherArsenal('Paul Skenes', {
statsCsv: STATS_CSV,
veloCsv: VELO_CSV,
resolveId: async (n) => (/skenes/i.test(n) ? 694973 : null),
});
expect(out.found).toBe(true);
expect(out.playerId).toBe(694973);
});
test('unresolvable name / empty input → { found:false } (never throws)', async () => {
const noId = await savant.getPitcherArsenal('Nobody Here', { statsCsv: STATS_CSV, resolveId: async () => null });
expect(noId.found).toBe(false);
const blank = await savant.getPitcherArsenal('', { statsCsv: STATS_CSV });
expect(blank.found).toBe(false);
});
test('quote-aware CSV parser keeps a comma inside a quoted field', () => {
const rows = savant.__internals.parseCsv('"last, first",id\n"Skenes, Paul",694973');
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('694973');
expect(rows[0]['last, first']).toBe('Skenes, Paul');
});
test('pctOrNull scales a fraction but leaves whole percents alone; null stays null', () => {
const { pctOrNull } = savant.__internals;
expect(pctOrNull('0.324')).toBe(32.4);
expect(pctOrNull('32.4')).toBe(32.4);
expect(pctOrNull('')).toBeNull();
expect(pctOrNull(null)).toBeNull();
});
});
+28
View File
@@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => {
expect(cache.store['grades:mlb'].grades).toHaveLength(2); expect(cache.store['grades:mlb'].grades).toHaveLength(2);
}); });
it('Wave 2A — threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => {
const cache = memCache();
const d = deps(cache);
// Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape).
d.resolveStats = async (player) => (player === 'Aaron Judge'
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 }
: { found: true, classifierInput: {}, espnId: 4433403 });
await svc.runSnapshot('mlb', d);
const snap = cache.store['snapshot:mlb:latest'];
const judge = snap.grades.find((g) => g.player === 'Aaron Judge');
const betts = snap.grades.find((g) => g.player === 'Mookie Betts');
expect(judge.playerId).toBe(592450);
expect(betts.espnId).toBe(4433403);
// grades:{sport} inherits the same ids (GameCard/Explore read from it).
const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge');
expect(g.playerId).toBe(592450);
});
it('Wave 2A — no resolved id → enriched grade carries null ids (monogram path)', async () => {
const cache = memCache();
const d = deps(cache);
d.resolveStats = async () => ({ found: false }); // nothing resolves
await svc.runSnapshot('mlb', d);
const snap = cache.store['snapshot:mlb:latest'];
expect(snap.grades[0].playerId).toBeNull();
expect(snap.grades[0].espnId).toBeNull();
});
it('rotates latest → previous and computes deltas on the second run', async () => { it('rotates latest → previous and computes deltas on the second run', async () => {
const cache = memCache(); const cache = memCache();
let line = 1.5; let line = 1.5;
+11 -2
View File
@@ -64,14 +64,23 @@ describe('SPORT_MARKETS — isolation', () => {
expect(wc).not.toMatch(/batter_/); expect(wc).not.toMatch(/batter_/);
}); });
test('every market list ends with `spreads`', () => { test('every PLAYER-PROP market list ends with `spreads`', () => {
for (const list of Object.values(SPORT_MARKETS)) { for (const [sport, list] of Object.entries(SPORT_MARKETS)) {
// Wave 6 — MMA is a GAME-level sport (h2h + round totals only); it has
// no player-prop `spreads` market and odds-api 422s if one is sent.
if (sport === 'mma') continue;
// We don't require spreads to be the literal final segment, // We don't require spreads to be the literal final segment,
// only that it's present in the comma-separated list. // only that it's present in the comma-separated list.
expect(list.split(',')).toContain('spreads'); expect(list.split(',')).toContain('spreads');
} }
}); });
test('MMA market list is game-level (h2h + totals), no spreads/player props', () => {
expect(SPORT_MARKETS.mma).toBe('h2h,totals');
expect(SPORT_MARKETS.mma).not.toMatch(/spreads/);
expect(SPORT_MARKETS.mma).not.toMatch(/player_/);
});
test('SPORT_MARKETS is frozen at the top level', () => { test('SPORT_MARKETS is frozen at the top level', () => {
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true); expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
}); });
+130
View File
@@ -0,0 +1,130 @@
// Wave 3 (Addition 2) — RECORD BY GRADE TIER.
// The tier calibration IS the credibility: A+ wins more than A, A more than
// B. This suite locks (a) the shared row-building logic, (b) the tier order,
// and (c) that the ONE shared TierRecord component is imported + rendered on
// all three record surfaces (dashboard, /u, ledger). Plain-JS Jest — the .tsx
// is asserted against source (same pattern as publicProfilePage/vyndrAppShell).
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
const { buildTierRows, TIER_ORDER, anyTierReady, isEdgeTier } = require('../../web/src/lib/tierRecord');
describe('buildTierRows — honesty rules', () => {
// A ≥20 tier carries a real hit_pct (gate applied upstream); a small tier
// arrives with hit_pct null even though it has settles.
const byTier = {
'A+': { settled: 22, hits: 18, misses: 4, pushes: 0, hit_pct: 82 },
A: { settled: 25, hits: 17, misses: 8, pushes: 0, hit_pct: 68 },
B: { settled: 3, hits: 2, misses: 1, pushes: 0, hit_pct: null }, // small sample
};
test('shows each tier W-L record ALWAYS, even at small n', () => {
const rows = buildTierRows(byTier);
const b = rows.find((r) => r.tier === 'B');
expect(b).toBeTruthy();
expect(b.record).toBe('2-1'); // honest at n=3
const ap = rows.find((r) => r.tier === 'A+');
expect(ap.record).toBe('18-4');
});
test('shows the hit-% ONLY when hit_pct != null; else RECORD BUILDING', () => {
const rows = buildTierRows(byTier);
const ap = rows.find((r) => r.tier === 'A+');
expect(ap.ready).toBe(true);
expect(ap.hitPct).toBe(82);
// Small-sample tier: no percentage, building copy with the settled count.
const b = rows.find((r) => r.tier === 'B');
expect(b.ready).toBe(false);
expect(b.hitPct).toBeNull();
expect(b.buildingLabel).toBe('RECORD BUILDING · 3 settled');
});
test('never fabricates a 0% from a null hit_pct (Number(null)===0 guard)', () => {
const rows = buildTierRows({ C: { settled: 5, hits: 0, misses: 0, pushes: 0, hit_pct: null } });
expect(rows[0].hitPct).toBeNull();
expect(rows[0].ready).toBe(false);
});
test('omits tiers with zero settled reads (no fake empty rows)', () => {
const rows = buildTierRows({ A: { settled: 0, hits: 0, misses: 0, hit_pct: null }, B: { settled: 4, hits: 3, misses: 1, hit_pct: null } });
expect(rows.map((r) => r.tier)).toEqual(['B']);
});
test('empty / missing map → no rows (self-hides)', () => {
expect(buildTierRows(null)).toEqual([]);
expect(buildTierRows(undefined)).toEqual([]);
expect(buildTierRows({})).toEqual([]);
});
});
describe('tier order + edge color contract', () => {
test('renders A+ → F in canonical order', () => {
expect(TIER_ORDER).toEqual(['A+', 'A', 'B', 'C', 'D', 'F']);
});
test('buildTierRows preserves A+ → F order regardless of map insertion order', () => {
const rows = buildTierRows({
F: { settled: 2, hits: 0, misses: 2, hit_pct: null },
A: { settled: 3, hits: 2, misses: 1, hit_pct: null },
'A+': { settled: 4, hits: 4, misses: 0, hit_pct: null },
C: { settled: 5, hits: 2, misses: 3, hit_pct: null },
});
expect(rows.map((r) => r.tier)).toEqual(['A+', 'A', 'C', 'F']);
});
test('GLOW RESERVED FOR A-TIER — only A+/A are edge tiers', () => {
expect(isEdgeTier('A+')).toBe(true);
expect(isEdgeTier('A')).toBe(true);
expect(isEdgeTier('B')).toBe(false);
expect(isEdgeTier('C')).toBe(false);
expect(isEdgeTier('F')).toBe(false);
});
test('anyTierReady reflects the upstream n≥20 gate', () => {
expect(anyTierReady({ A: { settled: 5, hits: 3, misses: 2, hit_pct: null } })).toBe(false);
expect(anyTierReady({ A: { settled: 25, hits: 17, misses: 8, hit_pct: 68 } })).toBe(true);
});
});
describe('the ONE shared component reaches all three record surfaces', () => {
test('TierRecord.tsx exists and is exported from the vyndr barrel', () => {
expect(exists('components/vyndr/TierRecord.tsx')).toBe(true);
expect(read('components/vyndr/index.ts')).toContain("export { default as TierRecord } from './TierRecord'");
});
test('TierRecord renders W-L always + % only when ready, else BUILDING', () => {
const src = read('components/vyndr/TierRecord.tsx');
expect(src).toContain('buildTierRows');
expect(src).toContain('{r.record}'); // W-L always
expect(src).toContain('{r.hitPct}% HIT'); // % only in the ready branch
expect(src).toContain('RECORD BUILDING · {r.settled} settled');
});
test('/u PublicProfile imports AND renders TierRecord', () => {
const src = read('app/u/[handle]/PublicProfile.tsx');
expect(src).toMatch(/import TierRecord from '@\/components\/vyndr\/TierRecord'/);
expect(src).toContain('<TierRecord');
expect(src).toContain('by_tier'); // the aggregate carries the tier buckets
});
test('dashboard imports AND renders TierRecord', () => {
const src = read('app/dashboard/page.tsx');
expect(src).toMatch(/TierRecord/);
expect(src).toContain('<TierRecord');
});
test('ledger uses the shared TierRecord (single source, no inline table)', () => {
const src = read('app/ledger/page.tsx');
expect(src).toContain('TierRecord');
expect(src).toContain('<TierRecord');
// the old inline per-tier component is gone (folded into the shared one)
expect(src).not.toContain('function TierCalibration');
});
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 24" width="72" height="24" role="img" aria-label="bet365"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#3EA76B">bet365</text></svg>

After

Width:  |  Height:  |  Size: 283 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 24" width="86" height="24" role="img" aria-label="BetMGM"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="0.00em" fill="#C8A24B">BetMGM</text></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 24" width="112" height="24" role="img" aria-label="BetRivers"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#3E8FD6">BetRivers</text></svg>

After

Width:  |  Height:  |  Size: 291 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 24" width="88" height="24" role="img" aria-label="Caesars"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="700" letter-spacing="0.02em" fill="#1A7F5A">Caesars</text></svg>

After

Width:  |  Height:  |  Size: 284 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124 24" width="124" height="24" role="img" aria-label="DraftKings"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#53D337">DraftKings</text></svg>

After

Width:  |  Height:  |  Size: 293 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 24" width="90" height="24" role="img" aria-label="FanDuel"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#1493FF">FanDuel</text></svg>

After

Width:  |  Height:  |  Size: 285 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 106 24" width="106" height="24" role="img" aria-label="Hard Rock"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="0.00em" fill="#D4A24B">Hard Rock</text></svg>

After

Width:  |  Height:  |  Size: 290 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 24" width="98" height="24" role="img" aria-label="Pinnacle"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="700" letter-spacing="0.01em" fill="#C8434F">Pinnacle</text></svg>

After

Width:  |  Height:  |  Size: 286 B

+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Combat (MMA/UFC) fight-cards proxy (Wave 6, S25 rule — Express isn't
* reachable from the browser directly). Forwards to /api/combat/:date.
* Off-card windows return an empty-but-valid card list so the UI degrades
* to the honest empty state, never a crash.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ date: string }> }) {
const { date } = await params;
const d = String(date || '').toLowerCase();
try {
const upstream = await fetch(`${BACKEND_URL}/api/combat/${encodeURIComponent(d)}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ date: d, events: [], source: 'espn' });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Single fight-card proxy (Wave 6, S25 rule). Forwards to /api/fight/:id.
* Unknown/unavailable card → 404 (honest, no fabricated card).
*/
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const fid = String(id || '').replace(/[^0-9]/g, '');
if (!fid) return NextResponse.json({ error: 'not found' }, { status: 404 });
try {
const upstream = await fetch(`${BACKEND_URL}/api/fight/${encodeURIComponent(fid)}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'card not found' }, { status: 404 });
}
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Pitcher arsenal proxy (Wave 5B). Forwards GET
* /api/stats/pitcher/:name/arsenal to the Express stats route (Baseball Savant
* pitch mix / velo / whiff). Thin pass-through; preserves ?sport=. On any
* upstream failure it returns { found:false } so the PitcherArsenal card
* self-hides honestly rather than showing an error.
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) {
const { name } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(
`${BACKEND_URL}/api/stats/pitcher/${encodeURIComponent(name)}/arsenal${qs}`,
{ method: 'GET', headers: { Accept: 'application/json' } },
);
const data = await upstream.json().catch(() => ({ found: false }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ found: false }, { status: 200 });
}
}
+108 -16
View File
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
// below as intelligence layers on top of the raw odds. // below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate'; import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header. // Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import { AccuracyBadge, Skeleton, SkeletonList, TierRecord } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy. // Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState'; import { emptyStateCopy } from '@/lib/emptyState';
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
@@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule';
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first // varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
// paint ALWAYS proves the model (#1, #13, Part 6). // paint ALWAYS proves the model (#1, #13, Part 6).
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter'; import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
// Wave 4A (Step 3) — OUTLOOK MODE: the "Today's games" empty branch is never a
// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule.
import { buildOutlook } from '@/lib/outlook';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
import { currentAccessToken } from '@/lib/authToken'; import { currentAccessToken } from '@/lib/authToken';
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
WNBA: '#FFB347', WNBA: '#FFB347',
}; };
/**
* Wave 4A (Step 3) — the dashboard OUTLOOK surface for an empty slate. Replaces
* the old "NO SLATE" dead-end CTA: the month-aware header + yesterday's PROVEN
* A-tier receipts + tomorrow's date-pinned schedule preview (all REAL,
* always-available data — never an invented line). Self-fetches both; the
* header always renders, so the grid is never blank.
*/
function DashboardOutlook({ sport }: { sport: Sport }) {
const [settled, setSettled] = useState<ModelEntry[] | null>(null);
const [tomorrow, setTomorrow] = useState<ScheduleApiGame[] | null>(null);
useEffect(() => {
let active = true;
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
.catch(() => { if (active) setSettled([]); });
return () => { active = false; };
}, [sport]);
useEffect(() => {
let active = true;
// Tomorrow, ET — the schedule route is date-pinned (free / cached).
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(Date.now() + 86_400_000));
fetch(`/api/schedule/${sport.toLowerCase()}?date=${date}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setTomorrow((Array.isArray(d?.games) ? d.games : []).map((g: ScheduleApiGame) => ({ ...g, sport: sport.toLowerCase() }))); })
.catch(() => { if (active) setTomorrow([]); });
return () => { active = false; };
}, [sport]);
const outlook = buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceiptView[]; tomorrow?: OutlookGameView[] };
const receipts: OutlookReceiptView[] = outlook.receipts ?? [];
const preview: OutlookGameView[] = outlook.tomorrow ?? [];
const { title, body } = emptyStateCopy(sport.toLowerCase());
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 24, display: 'grid', gap: 16 }}>
<div>
<p className="mono lbl" style={{ color: 'var(--amber)' }}>OUTLOOK</p>
<h3 style={{ fontSize: 18, fontWeight: 700, marginTop: 4 }}>{title}</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
</div>
{receipts.length > 0 && (
<div>
<div className="mono lbl" style={{ color: 'var(--grade-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
{receipts.map((r: OutlookReceiptView, i: number) => (
<div key={`${r.player}-${r.stat}-${i}`} className="surface" style={{ minWidth: 190, padding: 14, border: '1px solid var(--grade-a)', borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>{String(r.sport).toUpperCase()}</span>
<GradeBadge grade={r.grade} size={28} glow />
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.player}</div>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', fontVariantNumeric: 'tabular-nums' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, color: 'var(--grade-a)', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
&#10003; HIT{r.actual != null ? ` (${r.actual})` : ''}
</p>
</div>
))}
</div>
</div>
)}
{preview.length > 0 && (
<div>
<div className="mono lbl" style={{ color: 'var(--text-tertiary)', marginBottom: 10 }}>TOMORROW · SCHEDULE lines post on the day</div>
<div style={{ display: 'grid', gap: 8 }}>
{preview.map((g: OutlookGameView) => (
<div key={g.id} className="mono" style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 13, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700 }}>{g.away} @ {g.home}</span>
{g.time && <span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{formatTime(g.time)}</span>}
</div>
))}
</div>
</div>
)}
<div>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
</div>
</div>
);
}
// Render-only views of the buildOutlook output (the JS adapter has no TS types).
interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null }
interface OutlookGameView { id: string; away: string; home: string; time: string | null }
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth(); const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
@@ -306,6 +401,15 @@ export default function DashboardPage() {
}} }}
/> />
{/* Wave 3 (Addition 2) — the model's record BY GRADE TIER. Self-fetches
/api/ledger/model (scoped to the selected sport) and fully self-hides
until a tier has a settled read — no empty header. The SAME shared
TierRecord that renders on /u and the ledger; the calibration
gradient is the proof higher grades win more. */}
<div style={{ marginTop: 28 }}>
<TierRecord sport={sport} title="MODEL RECORD BY GRADE" dense />
</div>
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on {/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
the varying signal (selectTopGrades: tier → confidence → edge) so the the varying signal (selectTopGrades: tier → confidence → edge) so the
row isn't identical-weight noise; when tonight is empty we fall back to row isn't identical-weight noise; when tonight is empty we fall back to
@@ -422,21 +526,9 @@ export default function DashboardPage() {
{games === null ? ( {games === null ? (
<SkeletonRow stacked /> <SkeletonRow stacked />
) : games.length === 0 ? ( ) : games.length === 0 ? (
// Session 57 (Phase 0) — honest per-sport empty state (spec §6): // Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
// an off-season sport says when it returns; in-season = off-day. // Yesterday's proven receipts + tomorrow's real schedule fill the grid.
<div <DashboardOutlook sport={sport} />
className="surface diagonal-cut tex-scan"
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
{emptyStateCopy(sport.toLowerCase()).body}
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
</div>
</div>
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
{games.map((g, idx) => ( {games.map((g, idx) => (
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { FightCard, EmptyState } from '@/components/vyndr';
import type { FighterTape } from '@/components/vyndr';
interface RawFighter {
id?: string | null;
name: string;
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
stance?: string | null;
reach?: string | number | null;
blend?: { archetype: string; weight: number }[] | null;
pedigrees?: string[] | null;
}
interface RawBout {
id?: string | null;
weightClass?: string | null;
rounds?: number | null;
status?: string | null;
fighters: RawFighter[];
odds?: { moneyline?: { home?: number | null; away?: number | null } | null; roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null } | null;
verdict?: { verdict: string; edgeSide?: 'a' | 'b' | null; summary?: string } | null;
}
interface RawEvent {
id?: string | null;
name?: string | null;
shortName?: string | null;
date?: string | null;
venue?: string | null;
bouts: RawBout[];
}
export default function FightCardClient({ id }: { id: string }) {
const [event, setEvent] = useState<RawEvent | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'empty'>('loading');
useEffect(() => {
let active = true;
fetch(`/api/fight/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!active) return;
const ev: RawEvent | null = d && d.event ? d.event : null;
if (ev && Array.isArray(ev.bouts) && ev.bouts.length > 0) {
setEvent(ev);
setState('ready');
} else {
setState('empty');
}
})
.catch(() => { if (active) setState('empty'); });
return () => { active = false; };
}, [id]);
if (state === 'loading') {
return (
<div className="mono" style={{ padding: '80px 24px', textAlign: 'center', color: 'var(--text-2)', letterSpacing: '0.2em', fontSize: 12 }}>
LOADING THE CARD
</div>
);
}
if (state === 'empty' || !event) {
return (
<EmptyState
code="NO CARD SCHEDULED"
title="No fight card here"
message="The octagon is dark right now. Combat cards post the week of a UFC event — check back closer to fight night."
actions={[{ label: 'BACK TO THE SLATE', href: '/dashboard', primary: true }]}
/>
);
}
const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null;
return (
<div style={{ maxWidth: 680, margin: '0 auto', padding: '24px 16px 80px' }}>
<header style={{ marginBottom: 20 }}>
<div className="mono" style={{ fontSize: 10, letterSpacing: '0.18em', color: 'var(--text-2)', marginBottom: 6 }}>
{[event.shortName, dateStr, event.venue].filter(Boolean).join(' · ') || 'UFC'}
</div>
<h1 style={{ fontSize: 24, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
{event.name || 'Fight Card'}
</h1>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{event.bouts.map((bout, i) => {
const fighters: FighterTape[] = (bout.fighters || []).slice(0, 2).map((f) => ({
id: f.id,
name: f.name,
record: f.record,
stance: f.stance,
reach: f.reach,
blend: f.blend,
pedigrees: f.pedigrees,
}));
return (
<FightCard
key={bout.id || `${i}`}
weightClass={bout.weightClass}
rounds={bout.rounds}
status={bout.status}
fighters={fighters}
odds={bout.odds}
verdict={bout.verdict}
/>
);
})}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import FightCardClient from './FightCardClient';
/**
* /fight/[id] (Wave 6 — combat intelligence). Thin server wrapper for page
* metadata; the interactive tale-of-the-tape cards live in the client
* component. Off-card windows self-hide to the shared EmptyState.
*/
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
await params;
return {
title: 'Fight Card — VYNDR Combat',
description: 'Tale-of-the-tape, style-blend archetypes, and moneyline / round-total lines for the UFC card. A MODEL style read — not a settled grade.',
};
}
export default async function FightPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <FightCardClient id={String(id || '')} />;
}
+3
View File
@@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
/* Terminal's multi-column grid stacks on mobile. */ /* Terminal's multi-column grid stacks on mobile. */
@media (max-width: 768px) { @media (max-width: 768px) {
.terminal-grid { grid-template-columns: 1fr !important; } .terminal-grid { grid-template-columns: 1fr !important; }
/* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal
table keeps its own internal grid. */
.parsenal-grid { grid-template-columns: 1fr !important; }
} }
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */ /* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
+17 -27
View File
@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { GradePill } from '@/components/GradeCard'; import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
/** /**
@@ -177,30 +178,6 @@ export default function LedgerPage() {
); );
} }
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
* the proof the grades mean something. A tier under n≥20 shows "building",
* never a small-sample percentage. Self-hides until ANY tier is ready. */
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const tiers = agg.by_tier || {};
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
if (!anyReady) return null;
return (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
const b = tiers[t];
const ready = b.hit_pct != null;
return (
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
</span>
);
})}
</div>
);
}
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives /** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
* in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
* distribution renders as one giant bar + six empty stubs — it reads as * distribution renders as one giant bar + six empty stubs — it reads as
@@ -305,7 +282,20 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</div> </div>
)} )}
<ClvDistribution agg={agg} /> <ClvDistribution agg={agg} />
<TierCalibration agg={agg} minSample={minSample} /> {/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
identical here, on the dashboard, and on /u. */}
<TierRecord byTier={agg.by_tier} minSample={minSample} />
{/* Wave 5A (D2) — the shareable house/model profile. Same record, a
public page you can hand a partner. */}
<div style={{ marginTop: 14, textAlign: 'right' }}>
<Link
href="/u/vyndr"
className="mono"
style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', textDecoration: 'none' }}
>
VIEW AS PUBLIC PAGE
</Link>
</div>
</div> </div>
); );
} }
@@ -365,7 +355,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
{row.side} {row.line} {row.stat.replace(/_/g, ' ')} {row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p> </p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}> <p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{/* model_value is MODEL output — always labeled, never blended with market numbers. */} {/* model_value is MODEL output — always labeled, never blended with market numbers. */}
{row.model_value != null && <span> · MODEL {row.model_value}</span>} {row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p> </p>
+10 -1
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero'; import Hero from '@/components/Hero';
@@ -85,8 +86,16 @@ export default function Home() {
{/* Session 58 (work-order 1.4) — the public model record (proof-strip {/* Session 58 (work-order 1.4) — the public model record (proof-strip
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
the real hit% + beat-close%. Self-hides with no data. */} the real hit% + beat-close%. Self-hides with no data. */}
<div style={{ padding: '4px 16px 12px' }}> <div style={{ padding: '4px 16px 12px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<ModelRecord /> <ModelRecord />
{/* Wave 5A (D2) — the shareable public model record (the house profile). */}
<Link
href="/u/vyndr"
className="mono"
style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-tertiary)', textDecoration: 'none' }}
>
VIEW PUBLIC RECORD
</Link>
</div> </div>
{/* Founder-seat scarcity meter (§12) */} {/* Founder-seat scarcity meter (§12) */}
<div style={{ padding: '0 16px 8px' }}> <div style={{ padding: '0 16px 8px' }}>
+340
View File
@@ -0,0 +1,340 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParlay, legKey, type ParlayLeg } from "@/contexts/ParlayContext";
import { useAuth } from "@/contexts/AuthContext";
import GradeBadge from "@/components/vyndr/GradeBadge";
import SportBadge from "@/components/vyndr/SportBadge";
import ArchetypeBadge from "@/components/vyndr/ArchetypeBadge";
import { statLabel } from "@/lib/gradeAdapter";
/**
* Parlay Lab (Wave 4B) — the CORRELATION BUILDER / PARLAY SLIP surface.
*
* The correlation MATH already lives in parlayService + /api/parlay/grade +
* ParlayContext (combined grade, correlation, payout, tier-aware maxLegs). The
* only thing missing was a leg source INDEPENDENT of the live slate: this page
* browses tonight's PRE-GRADED props from /api/snapshot/:sport (and resolves a
* player via /api/players/search), so a slip can be assembled even with no games
* on screen. Legs go in via useParlay().addLeg (deduped by legKey); the slip
* reads the combined grade / correlation flag / payout straight off the context.
*/
type Sport = ParlayLeg["sport"]; // 'NBA' | 'MLB' | 'WNBA'
const SPORTS: Sport[] = ["MLB", "NBA", "WNBA"];
const TIER_MAX = { free: 2, analyst: 4, desk: 6 } as const;
function tierMaxLegs(tier: string): number {
return TIER_MAX[tier as keyof typeof TIER_MAX] ?? 2;
}
interface SnapshotGrade {
player?: string;
player_name?: string;
stat_type?: string;
stat?: string;
line?: number;
side?: string;
grade?: string;
confidence?: number;
team?: string;
game?: string;
archetype?: string;
}
interface BrowseProp {
player: string;
stat: string; // canonical stat key (for addLeg + statLabel)
line: number;
direction: "over" | "under";
grade: string;
confidence: number;
team?: string;
game?: string;
archetype?: string;
key: string; // legKey
}
function toBrowseProp(g: SnapshotGrade): BrowseProp | null {
const player = String(g.player || g.player_name || "").trim();
const stat = String(g.stat_type || g.stat || "").toLowerCase().trim();
const line = typeof g.line === "number" && Number.isFinite(g.line) ? g.line : NaN;
if (!player || !stat || Number.isNaN(line)) return null;
const direction = String(g.side || "over").toLowerCase().startsWith("u") ? "under" : "over";
const grade = String(g.grade || "").trim();
if (!grade || grade === "—") return null; // only graded props are addable legs
return {
player,
stat,
line,
direction,
grade,
confidence: typeof g.confidence === "number" ? g.confidence : 0,
team: g.team ? String(g.team) : undefined,
game: g.game ? String(g.game) : undefined,
archetype: g.archetype ? String(g.archetype) : undefined,
key: legKey({ player, stat, line, direction }),
};
}
export default function ParlayLabPage() {
const { legs, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs, setMaxLegs, atCap } = useParlay();
const { tier } = useAuth();
const fullLab = tier === "desk" || tier === "analyst";
const [sport, setSport] = useState<Sport>("MLB");
const [rawProps, setRawProps] = useState<BrowseProp[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const [suggest, setSuggest] = useState<string[]>([]);
// Tier -> leg cap (free 2 / analyst 4 / desk 6), same wiring as ParlayPanel.
useEffect(() => { setMaxLegs(tierMaxLegs(tier || "free")); }, [tier, setMaxLegs]);
// Leg source #1 — the pre-graded slate (independent of the live board).
useEffect(() => {
let alive = true;
setLoading(true);
fetch(`/api/snapshot/${sport.toLowerCase()}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!alive) return;
const grades: SnapshotGrade[] = d && Array.isArray(d.grades) ? d.grades : [];
const mapped = grades.map(toBrowseProp).filter((p): p is BrowseProp => !!p);
// Dedupe by legKey, keep highest confidence, sort A+ -> F then confidence.
const byKey = new Map<string, BrowseProp>();
for (const p of mapped) {
const prev = byKey.get(p.key);
if (!prev || p.confidence > prev.confidence) byKey.set(p.key, p);
}
setRawProps(Array.from(byKey.values()));
})
.catch(() => { if (alive) setRawProps([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [sport]);
// Leg source #2 — the canonical player resolver, so search works even for a
// player with no graded prop tonight (honest "no reads yet" instead of blank).
useEffect(() => {
const term = q.trim();
if (term.length < 2) { setSuggest([]); return; }
let alive = true;
const t = setTimeout(() => {
fetch(`/api/players/search?sport=${sport}&q=${encodeURIComponent(term)}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!alive) return;
const players = d && Array.isArray(d.players) ? d.players : [];
setSuggest(players.map((p: { full_name?: string }) => String(p.full_name || "")).filter(Boolean).slice(0, 6));
})
.catch(() => { if (alive) setSuggest([]); });
}, 220);
return () => { alive = false; clearTimeout(t); };
}, [q, sport]);
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
const list = term ? rawProps.filter((p) => p.player.toLowerCase().includes(term)) : rawProps;
return list.slice(0, 40);
}, [rawProps, q]);
const toggle = useCallback((p: BrowseProp) => {
const existing = legs.find((l) => legKey(l) === p.key);
if (existing) { removeLeg(existing.id); return; }
if (atCap) { if (typeof window !== "undefined" && !fullLab && window.__goPaywall) window.__goPaywall(); return; }
addLeg({
sport,
player: p.player,
stat: p.stat,
line: p.line,
direction: p.direction,
grade: p.grade,
confidence: p.confidence,
team: p.team,
game: p.game,
archetype: p.archetype,
});
}, [legs, removeLeg, addLeg, atCap, fullLab, sport]);
const searchedButEmpty = q.trim().length >= 2 && filtered.length === 0;
return (
<div style={{ maxWidth: 1040, margin: "0 auto", padding: "12px 16px 120px" }}>
{/* Header */}
<div style={{ marginBottom: 14 }}>
<h1 className="mono" style={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.28em", color: "var(--text-0)" }}>CORRELATION BUILDER</h1>
<p className="mono" style={{ fontSize: 10.5, letterSpacing: "0.14em", color: "var(--text-2)", marginTop: 4 }}>
PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD
</p>
</div>
{/* Sport tabs */}
<div style={{ display: "flex", gap: 6, marginBottom: 12, flexWrap: "wrap" }}>
{SPORTS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSport(s)}
className="mono"
style={{
padding: "6px 12px", borderRadius: 8, fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", cursor: "pointer",
background: s === sport ? "var(--g-a)" : "var(--bg-2)",
color: s === sport ? "#06060B" : "var(--text-1)",
border: `1px solid ${s === sport ? "var(--g-a)" : "var(--border)"}`,
}}
>
{s}
</button>
))}
</div>
{/* Search — independent of the slate; resolves via /api/players/search */}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search a player…"
aria-label="Search a player"
className="mono"
style={{
width: "100%", padding: "10px 12px", marginBottom: 6, borderRadius: 9, fontSize: 13,
background: "var(--bg-2)", border: "1px solid var(--border)", color: "var(--text-0)",
}}
/>
{suggest.length > 0 && (
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-2)", marginBottom: 10, letterSpacing: "0.04em" }}>
{suggest.join(" · ")}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, alignItems: "start" }} className="parlay-lab-grid">
{/* LEFT — tonight's A/B reads (the leg source) */}
<div style={{ borderRadius: 16, background: "var(--bg-1)", border: "1px solid var(--border)", overflow: "hidden" }}>
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
<SportBadge sport={sport} />
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.18em", color: "var(--text-0)" }}>TONIGHT&apos;S GRADED READS</span>
<span className="mono" style={{ fontSize: 9, letterSpacing: "0.12em", color: "var(--text-2)", marginLeft: "auto" }}>TAP TO ADD</span>
</div>
{loading ? (
<div className="mono" style={{ padding: 24, textAlign: "center", fontSize: 12, color: "var(--text-2)" }}>Loading tonight&apos;s reads</div>
) : filtered.length === 0 ? (
<div className="mono" style={{ padding: 24, textAlign: "center", fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>
{searchedButEmpty
? `No graded ${sport} reads for “${q.trim()}” yet — grades post on the next snapshot.`
: `No graded ${sport} reads on the board right now. Check back after the next snapshot.`}
</div>
) : (
<div>
{filtered.map((p) => {
const active = hasLeg(p.key);
return (
<button
key={p.key}
type="button"
onClick={() => toggle(p)}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 12, padding: "11px 16px",
borderTop: "1px solid var(--border)", background: active ? "rgba(0,212,160,.08)" : "transparent",
cursor: "pointer", textAlign: "left",
}}
aria-pressed={active}
>
<span style={{
width: 18, height: 18, flex: "none", borderRadius: 5, display: "flex", alignItems: "center", justifyContent: "center",
border: `1.5px solid ${active ? "var(--g-a)" : "var(--border-hi)"}`, background: active ? "var(--g-a)" : "transparent",
color: "#06060B", fontWeight: 800, fontSize: 12,
}} className="mono">{active ? "✓" : ""}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-0)" }}>{p.player} </span>
<span className="mono" style={{ fontSize: 12, color: "var(--text-1)" }}>{p.direction === "under" ? "u" : "o"}{p.line} {statLabel(p.stat)}</span>
<span className="mono" style={{ display: "block", fontSize: 8.5, letterSpacing: "0.1em", color: "var(--text-2)", marginTop: 2 }}>
{sport}{p.team ? ` · ${p.team}` : ""}
</span>
</span>
{p.archetype && <ArchetypeBadge archetype={p.archetype} size="sm" variant="tint" />}
<GradeBadge grade={p.grade} size="sm" />
</button>
);
})}
</div>
)}
</div>
{/* RIGHT — the PARLAY SLIP (reads combined/correlation/payout from context) */}
<div style={{ borderRadius: 16, background: "var(--bg-1)", border: "1px solid var(--border-hi)", overflow: "hidden", position: "sticky", top: 96 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.22em", color: "var(--text-0)" }}>PARLAY SLIP</span>
<span className="mono" style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text-2)" }}>{legs.length} LEG{legs.length === 1 ? "" : "S"}</span>
</div>
{/* legs */}
<div style={{ padding: "8px 10px", minHeight: 96, display: "flex", flexDirection: "column", gap: 6 }}>
{legs.length === 0 ? (
<div className="mono" style={{ padding: "22px 6px", textAlign: "center", fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>
No legs yet tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other.
</div>
) : (
legs.map((l) => (
<div key={l.id} style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border)" }}>
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="tint" />}
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-0)", whiteSpace: "nowrap" }}>{l.player}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--text-1)" }}>{statLabel(l.stat)} {l.direction === "under" ? "U" : "O"}{l.line}</span>
<GradeBadge grade={l.grade} size="sm" />
<button type="button" onClick={() => removeLeg(l.id)} aria-label={`Remove ${l.player}`} className="mono" style={{ marginLeft: "auto", background: "transparent", border: "none", color: "var(--miss)", cursor: "pointer", fontSize: 14 }}></button>
</div>
))
)}
</div>
{/* leg-cap notice (tier-aware) */}
{legs.length >= maxLegs && (
<div className="mono" style={{ fontSize: 11, color: "var(--amber)", padding: "0 12px 10px" }}>
{fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs — upgrade to Desk for 6."}
</div>
)}
{/* CAUTION · CORRELATION FLAG — surfaces parlayService's warning */}
{correlation?.warning && (
<div style={{ margin: "0 12px 12px", padding: "11px 13px", borderRadius: 10, background: "rgba(255,179,71,.08)", border: "1px solid rgba(255,179,71,.28)" }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.16em", color: "var(--amber)", fontWeight: 700, marginBottom: 5 }}> CAUTION · CORRELATION FLAG</div>
<div className="mono" style={{ fontSize: 10.5, lineHeight: 1.45, color: "var(--text-1)" }}>{correlation.warning}</div>
</div>
)}
{/* combined / grade / stake */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 1, background: "var(--border)", borderTop: "1px solid var(--border)" }}>
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>CORRELATION</div>
<div className="mono" style={{ fontSize: 15, fontWeight: 800, color: (correlation?.avg ?? 0) > 0.3 ? "var(--amber)" : "var(--g-a)" }}>
{legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "—"}
</div>
</div>
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>GRADE</div>
{legs.length >= 2 && combined ? <GradeBadge grade={combined.grade} size="md" glow /> : <span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--text-2)" }}>{grading ? "…" : "—"}</span>}
</div>
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>EST · $10</div>
{legs.length < 2 ? (
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--text-2)" }}></span>
) : fullLab ? (
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--g-a)" }}>{payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "—"}</span>
) : (
<span style={{ position: "relative", display: "inline-block" }}>
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--g-a)", filter: "blur(6px)", userSelect: "none" }}>$38.50</span>
<button type="button" onClick={() => typeof window !== "undefined" && window.__goPaywall && window.__goPaywall()} className="mono" style={{ position: "absolute", inset: 0, background: "transparent", border: "none", color: "var(--g-a)", cursor: "pointer", fontSize: 10, fontWeight: 700 }}>Upgrade </button>
</span>
)}
</div>
</div>
{legs.length > 0 && (
<button type="button" onClick={clear} className="mono" style={{ width: "100%", padding: 11, borderRadius: 0, fontWeight: 700, letterSpacing: "0.06em", fontSize: 11, cursor: "pointer", background: "transparent", border: "none", borderTop: "1px solid var(--border)", color: "var(--miss)" }}>
CLEAR SLIP
</button>
)}
</div>
</div>
</div>
);
}
+8
View File
@@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import ModelRecord from '@/components/vyndr/ModelRecord'; import ModelRecord from '@/components/vyndr/ModelRecord';
import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
import PitcherArsenal from '@/components/vyndr/PitcherArsenal';
import { archetypeInfo } from '@/lib/archetypes'; import { archetypeInfo } from '@/lib/archetypes';
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
@@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
</div> </div>
)} )}
{/* C2. PITCHER IDENTITY (Wave 5B) — Baseball Savant arsenal lens. MLB only;
the component (heading included) SELF-HIDES for non-pitchers / when
Savant has no arsenal. Context, not a graded market value. */}
{p.sport === 'mlb' && (
<PitcherArsenal name={p.player} sport={p.sport} heading pitcher={{ name: p.player, team: p.team }} />
)}
{/* D. ACTIVE PROPS */} {/* D. ACTIVE PROPS */}
{p.activeProps?.length > 0 && ( {p.activeProps?.length > 0 && (
<> <>
+7 -1
View File
@@ -36,7 +36,7 @@ export default function DeskShowcase() {
THE DESK · FLAGSHIP THE DESK · FLAGSHIP
</div> </div>
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}> <h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
A $1M terminal.{' '} Every grade, every alt line, live.{' '}
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span> <span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span> <span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
</h2> </h2>
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
</div> </div>
</div> </div>
{/* Wave 4A (Step 4) — this claim is now BACKED by a real feature: the
CONSENSUS vs MODEL strip (components/vyndr/MarketBreadth, fed by
lib/marketBreadth.collectBreadth) ships on the live slate/dashboard,
computing the median book line vs the model's projection. "live
line moves" is the existing snapshot line-deltas / LineSparkline.
No longer an empty promise — do not remove without removing those. */}
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden /> <span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}> <span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
+1 -1
View File
@@ -36,7 +36,7 @@ export const metadata: Metadata = {
export default function PricingPage() { export default function PricingPage() {
return ( return (
<main style={{ minHeight: '100vh' }}> <main style={{ minHeight: '100vh' }}>
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the {/* DS5 (#8) — Desk is the hero. The value-showing story leads, above the
grid, so the premium tier stops being an afterthought. */} grid, so the premium tier stops being an afterthought. */}
<DeskShowcase /> <DeskShowcase />
<Pricing /> <Pricing />
+22 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { currentAccessToken } from '@/lib/authToken'; import { currentAccessToken } from '@/lib/authToken';
import { classifyRenewal } from '@/lib/billingDisplay';
interface FullProfile { interface FullProfile {
id: string; id: string;
@@ -125,7 +126,7 @@ export default function ProfilePage() {
{tier !== 'free' && ( {tier !== 'free' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} /> <Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} /> <Stat label="Renews" value={renewalLabel(profile.subscription_end)} />
</div> </div>
)} )}
@@ -170,7 +171,7 @@ export default function ProfilePage() {
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}> <section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}> <p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
Cancellation scheduled. Access ends{' '} Cancellation scheduled. Access ends{' '}
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}. {accessEndsLabel(profile.subscription_end)}.
</p> </p>
</section> </section>
)} )}
@@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go
); );
} }
// Renewal render is guarded (Wave 1 trust bug) — VYNDR tiers are monthly, so a
// `subscription_end` far in the future is a comped/seed value, not a renewal.
// classifyRenewal decides; we never print a raw date the cadence can't justify.
function renewalLabel(subscriptionEnd: string | null): string {
const r = classifyRenewal(subscriptionEnd);
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
if (r.kind === 'none') return 'No scheduled renewal';
if (r.kind === 'lapsed') return 'Lapsed';
return '—';
}
// The cancel-scheduled line only shows a real near date; anything else falls to
// the honest "at period end" (never a fabricated 2036 access-end).
function accessEndsLabel(subscriptionEnd: string | null): string {
const r = classifyRenewal(subscriptionEnd);
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
return 'at period end';
}
function tierColor(tier: string): string { function tierColor(tier: string): string {
if (tier === 'desk') return 'var(--grade-a)'; if (tier === 'desk') return 'var(--grade-a)';
if (tier === 'analyst') return 'var(--grade-b)'; if (tier === 'analyst') return 'var(--grade-b)';
+25 -39
View File
@@ -16,7 +16,8 @@ import {
trackScanLimitHit, trackScanLimitHit,
trackUpgradeClicked, trackUpgradeClicked,
} from '@/lib/analytics'; } from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
type Sport = 'NBA' | 'MLB' | 'WNBA'; type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -119,6 +120,9 @@ export default function ScanPage() {
const [playerQuery, setPlayerQuery] = useState(''); const [playerQuery, setPlayerQuery] = useState('');
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]); const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
const [selectedPlayer, setSelectedPlayer] = useState<string>(''); const [selectedPlayer, setSelectedPlayer] = useState<string>('');
// Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric).
// Feeds the grade card's real headshot. null → team-colored monogram.
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [stat, setStat] = useState<string>('points'); const [stat, setStat] = useState<string>('points');
const [line, setLine] = useState<string>(''); const [line, setLine] = useState<string>('');
const [direction, setDirection] = useState<'over' | 'under'>('over'); const [direction, setDirection] = useState<'over' | 'under'>('over');
@@ -306,6 +310,7 @@ export default function ScanPage() {
setError(''); setError('');
setPlayerQuery(''); setPlayerQuery('');
setSelectedPlayer(''); setSelectedPlayer('');
setSelectedPlayerId(null);
setLine(''); setLine('');
}; };
@@ -442,7 +447,6 @@ export default function ScanPage() {
}} }}
> >
{tonightsPlayers.map((p) => { {tonightsPlayers.map((p) => {
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
const selected = selectedPlayer === p.name; const selected = selectedPlayer === p.name;
return ( return (
<button <button
@@ -450,6 +454,7 @@ export default function ScanPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedPlayer(p.name); setSelectedPlayer(p.name);
setSelectedPlayerId(null); // tonight chips carry no id → monogram
setPlayerQuery(p.name); setPlayerQuery(p.name);
setPlayerSuggestions([]); setPlayerSuggestions([]);
// If the player has exactly one stat type with // If the player has exactly one stat type with
@@ -475,22 +480,9 @@ export default function ScanPage() {
minWidth: 0, minWidth: 0,
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* Wave 2A — tonight's players carry no id → team-colored
<img monogram (branded, never a gray silhouette). */}
src={headshot} <PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
alt=""
width={24}
height={24}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
objectFit: 'cover',
}}
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}> <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{p.name} {p.name}
</span> </span>
@@ -511,6 +503,7 @@ export default function ScanPage() {
onChange={(e) => { onChange={(e) => {
setPlayerQuery(e.target.value); setPlayerQuery(e.target.value);
setSelectedPlayer(''); setSelectedPlayer('');
setSelectedPlayerId(null);
}} }}
autoComplete="off" autoComplete="off"
/> />
@@ -551,33 +544,25 @@ export default function ScanPage() {
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
setSelectedPlayer(p.full_name); setSelectedPlayer(p.full_name);
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
setPlayerQuery(p.full_name); setPlayerQuery(p.full_name);
setPlayerSuggestions([]); setPlayerSuggestions([]);
}} }}
style={suggestionStyle} style={suggestionStyle}
> >
{/* Session 19 — headshot in search suggestions. The {/* Wave 2A — real headshot in search suggestions. MLB's
/api/players/search response doesn't (yet) include /api/players/search carries the MLBAM id on p.id (numeric);
league IDs, so we hit ESPN-CDN fallback or NBA/WNBA ids are synthetic → guarded out → team-colored
silhouette. onError swaps in the silhouette when monogram. Never a gray silhouette. */}
the league hasn't published one. */} <span style={{ marginRight: 10, display: 'inline-flex' }}>
{/* eslint-disable-next-line @next/next/no-img-element */} <PlayerAvatar
<img name={p.full_name}
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })} sport={sport.toLowerCase() as HeadshotSport}
alt="" playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
width={28} team={p.team}
height={28} size={28}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
marginRight: 10,
objectFit: 'cover',
}}
/> />
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span> <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
{p.team && ( {p.team && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}> <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
@@ -731,6 +716,7 @@ export default function ScanPage() {
key={`${selectedPlayer}-${stat}-${line}-${direction}`} key={`${selectedPlayer}-${stat}-${line}-${direction}`}
data={mapScanToGradeResult({ data={mapScanToGradeResult({
player: selectedPlayer, player: selectedPlayer,
playerId: selectedPlayerId,
sport, sport,
stat, stat,
line: Number(line), line: Number(line),
+28 -4
View File
@@ -3,6 +3,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard'; import { GradePill } from '@/components/GradeCard';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import BookWordmark from '@/components/vyndr/BookWordmark';
import TierRecord from '@/components/vyndr/TierRecord';
import { buildTierRows } from '@/lib/tierRecord';
/** /**
* PublicProfile (A1 Session 10) — the public ledger record for one handle. * PublicProfile (A1 Session 10) — the public ledger record for one handle.
@@ -36,6 +39,8 @@ interface ProfileRow {
revised_from_grade?: string | null; revised_from_grade?: string | null;
} }
interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null }
interface ProfileAggregate { interface ProfileAggregate {
settled: number; settled: number;
hits: number; hits: number;
@@ -46,6 +51,9 @@ interface ProfileAggregate {
clv_beat: number; clv_beat: number;
beat_close_pct: number | null; beat_close_pct: number | null;
pending: number; pending: number;
// Wave 3 (Addition 2) — per-tier calibration (survives the API + proxy
// untouched, inside the aggregate). The credibility centerpiece.
by_tier?: Record<string, TierBucket>;
} }
interface ProfilePayload { interface ProfilePayload {
@@ -53,6 +61,11 @@ interface ProfilePayload {
aggregate: ProfileAggregate | null; aggregate: ProfileAggregate | null;
entries: ProfileRow[]; entries: ProfileRow[];
min_sample?: number; min_sample?: number;
// Wave 5A (D2) — the HOUSE/model profile: the public user_id=NULL record,
// shareable without a claimed public_profiles row. `label` distinguishes it
// from a user profile ("VYNDR MODEL · PUBLIC RECORD").
house?: boolean;
label?: string;
} }
const SPORT_COLOR: Record<string, string> = { const SPORT_COLOR: Record<string, string> = {
@@ -107,24 +120,35 @@ export default function PublicProfile({ handle }: { handle: string }) {
const agg = data.aggregate; const agg = data.aggregate;
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20; const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
const isHouse = Boolean(data.house);
return ( return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}> <section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 20 }}> <header style={{ marginBottom: 20 }}>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}> <p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
PUBLIC LEDGER {isHouse ? (data.label || 'VYNDR MODEL · PUBLIC RECORD') : 'PUBLIC LEDGER'}
</p> </p>
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}> <h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
@{data.handle} {isHouse ? 'VYNDR MODEL' : `@${data.handle}`}
</h1> </h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}> <p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
Every settled read. Wins and misses. Nothing curated. {isHouse
? 'The VYNDR models public record. Every graded read, wins and misses, closing-line value included.'
: 'Every settled read. Wins and misses. Nothing curated.'}
</p> </p>
</header> </header>
{/* Record header — never a percentage under min_sample settles. */} {/* Record header — never a percentage under min_sample settles. */}
<RecordHeader agg={agg} minSample={minSample} /> <RecordHeader agg={agg} minSample={minSample} />
{/* Wave 3 (Addition 2) — the record BY GRADE TIER, the proof that higher
grades win more. Same shared component as the dashboard + ledger. */}
{agg && buildTierRows(agg.by_tier || {}).length > 0 && (
<div className="surface diagonal-cut" style={{ padding: 20, marginBottom: 20 }}>
<TierRecord byTier={agg.by_tier} minSample={minSample} dense />
</div>
)}
{data.entries.length === 0 ? ( {data.entries.length === 0 ? (
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}> <div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}> <p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
@@ -272,7 +296,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
</div> </div>
</div> </div>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}> <p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>} {row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p> </p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
+4 -4
View File
@@ -11,7 +11,7 @@ export const contentType = 'image/png';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> { async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number; house: boolean } | null> {
try { try {
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
@@ -21,7 +21,7 @@ async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; be
const d = await r.json(); const d = await r.json();
const agg = d && d.aggregate; const agg = d && d.aggregate;
if (!agg || agg.hit_pct == null) return null; if (!agg || agg.hit_pct == null) return null;
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses }; return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses, house: Boolean(d.house) };
} catch { } catch {
return null; return null;
} }
@@ -49,10 +49,10 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} /> <div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}> <div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
{rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'} {rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER'}
</div> </div>
<div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}> <div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
@{h} {rec?.house ? 'VYNDR MODEL' : `@${h}`}
</div> </div>
{rec ? ( {rec ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}> <div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
+117
View File
@@ -0,0 +1,117 @@
import { ImageResponse } from 'next/og';
import type { NextRequest } from 'next/server';
/**
* /u/[handle]/portrait (Wave 5A, D2) — the PORTRAIT share crop (1080×1350,
* the 4:5 story/feed format from the design mockup). The square-crop OG
* (opengraph-image.tsx, 1200×630) unfurls a link; this is the standalone
* image you drop into a story or a partner deck.
*
* It reads the SAME /api/profiles/:handle payload — so the house handle
* (`vyndr`) renders the real public model record, a published user handle
* renders theirs, and anything else falls back to the tagline. NEVER a
* fabricated number: no record past the n-gate → the tagline, not a zero.
*
* Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule).
*/
export const dynamic = 'force-dynamic';
const SIZE = { width: 1080, height: 1350 };
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
async function fetchRecord(handle: string): Promise<{
hit_pct: number | null;
beat_close_pct: number | null;
hits: number;
misses: number;
house: boolean;
} | null> {
try {
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!r.ok) return null;
const d = await r.json();
const agg = d && d.aggregate;
if (!agg || agg.hit_pct == null) return null;
return {
hit_pct: agg.hit_pct,
beat_close_pct: agg.beat_close_pct ?? null,
hits: agg.hits,
misses: agg.misses,
house: Boolean(d.house),
};
} catch {
return null;
}
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ handle: string }> },
) {
const { handle } = await params;
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
const rec = await fetchRecord(h);
const heading = rec?.house ? 'VYNDR MODEL' : `@${h}`;
const eyebrow = rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
background: '#06060B',
color: '#E8E8F0',
padding: 90,
fontFamily: 'monospace',
}}
>
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 8, background: '#00D4A0' }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
{eyebrow}
</div>
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
{heading}
</div>
</div>
{rec ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 44 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 240, fontWeight: 900, lineHeight: 1, color: '#00D4A0' }}>{rec.hit_pct}%</div>
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 12 }}>HIT RATE · {rec.hits}-{rec.misses}</div>
</div>
{rec.beat_close_pct != null && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 128, fontWeight: 900, lineHeight: 1, color: '#FFFFFF' }}>{rec.beat_close_pct}%</div>
<div style={{ display: 'flex', fontSize: 30, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 12 }}>BEAT CLOSE</div>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', fontSize: 40, color: '#7A7A8E', lineHeight: 1.4 }}>
CLV-verified record · every settled read · misses included
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', fontSize: 60, fontWeight: 900, letterSpacing: '0.08em' }}>
<span style={{ color: '#FFFFFF' }}>VYND</span>
<span style={{ color: '#00D4A0' }}>R</span>
</div>
<div style={{ display: 'flex', fontSize: 26, color: '#4A4A5E', letterSpacing: '0.12em' }}>
NOTHING CURATED · NOTHING DELETED
</div>
</div>
</div>
),
{ ...SIZE },
);
}
+10 -11
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import type { HeadshotSport } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/** /**
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
{visible.map((p) => ( {visible.map((p) => (
<div key={`${p.name}-${p.stat}`} style={rowStyle}> <div key={`${p.name}-${p.stat}`} style={rowStyle}>
<span style={rankStyle}>#{p.rank}</span> <span style={rankStyle}>#{p.rank}</span>
<img {/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
src={getHeadshotUrl({ sport, playerId: p.playerId })} team-colored monogram otherwise. Never a gray silhouette. */}
alt={p.name} <PlayerAvatar
width={36} name={p.name}
height={36} sport={sport as HeadshotSport}
style={avatarStyle} playerId={p.playerId}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} team={p.team}
size={36}
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div> <div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28, flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
color: 'var(--text-tertiary, #6A6A78)', color: 'var(--text-tertiary, #6A6A78)',
}; };
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = { const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
-231
View File
@@ -1,231 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext';
import { GradePill } from './GradeCard';
import { trackParlayBuilt } from '@/lib/analytics';
interface ParlayGradeResponse {
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
decimal_odds?: number;
}
export default function ParlayTray() {
const { legs, isOpen, close, removeLeg, clear } = useParlay();
const [grading, setGrading] = useState(false);
const [parlayResult, setParlayResult] = useState<ParlayGradeResponse | null>(null);
// Reset the parlay grade whenever the leg set changes
useEffect(() => {
setParlayResult(null);
}, [legs]);
const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]);
const gradeParlay = async () => {
if (legs.length < 2) return;
setGrading(true);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/parlay/grade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
legs: legs.map((l) => ({
sport: l.sport,
player: l.player,
stat_type: l.stat,
line: l.line,
direction: l.direction,
})),
}),
});
const data = (await res.json()) as ParlayGradeResponse;
if (res.ok) {
setParlayResult(data);
trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade });
}
} finally {
setGrading(false);
}
};
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Parlay tray"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
>
<button
aria-label="Close parlay tray"
onClick={close}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
border: 'none',
cursor: 'pointer',
}}
/>
<section
className="surface-elevated diagonal-cut animate-fade-up"
style={{
position: 'relative',
width: '100%',
maxWidth: 560,
maxHeight: '85vh',
margin: '0 auto',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
overflowY: 'auto',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Parlay tray</h2>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
{legs.length} LEG{legs.length === 1 ? '' : 'S'} · {sports.join(' · ') || 'ADD A LEG'}
</p>
</div>
<button onClick={close} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 12 }}>
Close
</button>
</header>
{legs.length === 0 ? (
<EmptyTrayCopy />
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{legs.map((l) => (
<LegRow key={l.id} leg={l} onRemove={() => removeLeg(l.id)} />
))}
</ul>
)}
{parlayResult && (
<div
className="surface diagonal-cut"
style={{
padding: 16,
textAlign: 'center',
border: '1px solid var(--border-focus)',
}}
>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
PARLAY GRADE
</p>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
<GradePill grade={parlayResult.parlay_grade} confidence={parlayResult.parlay_confidence} />
</div>
{parlayResult.correlation_flags.length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
textAlign: 'left',
borderRadius: 8,
background: 'rgba(255,179,71,0.10)',
border: '1px solid rgba(255,179,71,0.30)',
}}
>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--grade-c)', marginBottom: 4 }}>
CORRELATION WARNINGS
</p>
{parlayResult.correlation_flags.map((f, i) => (
<p key={i} style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
{f.detail}
</p>
))}
</div>
)}
</div>
)}
{legs.length > 0 && (
<footer style={{ display: 'grid', gap: 8 }}>
<button
onClick={gradeParlay}
disabled={legs.length < 2 || grading}
className={grading ? 'shimmer-loading' : 'btn-primary'}
style={{ padding: 14, fontWeight: 600, fontSize: 14, border: 'none', borderRadius: 12, color: 'var(--text-primary)', cursor: legs.length < 2 ? 'not-allowed' : 'pointer', opacity: legs.length < 2 ? 0.4 : 1 }}
>
{grading ? 'Running correlation analysis…' : legs.length < 2 ? 'Add 2+ legs to grade' : 'Grade parlay'}
</button>
<button onClick={clear} className="btn-ghost" style={{ padding: 12, fontSize: 13 }}>
Clear tray
</button>
</footer>
)}
</section>
</div>
);
}
function EmptyTrayCopy() {
return (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
NO LEGS YET
</p>
<p style={{ marginTop: 12, color: 'var(--text-secondary)', fontSize: 14, lineHeight: 1.6 }}>
Read a prop, hit <strong>Add to Parlay</strong>, and we&apos;ll build the slip here.
We grade overall correlation and surface the legs that secretly fight each other.
</p>
</div>
);
}
function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) {
return (
<li
className="surface"
style={{
padding: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{leg.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<GradePill grade={leg.grade} />
<button
onClick={onRemove}
aria-label={`Remove ${leg.player}`}
className="btn-ghost"
style={{ padding: '4px 10px', fontSize: 11 }}
>
</button>
</div>
</li>
);
}
+1 -1
View File
@@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [
highlight: false, highlight: false,
}, },
{ {
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal" // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing
// story and the single primary CTA on the grid (color contract #9: never // story and the single primary CTA on the grid (color contract #9: never
// two competing green CTAs). $44.99 regular, $34.99 for founders. // two competing green CTAs). $44.99 regular, $34.99 for founders.
id: 'desk', id: 'desk',
+177 -12
View File
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard'; import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow'; import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter'; import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
// live games (and it's not a fetch failure) the grid shows REAL data —
// yesterday's proven receipts + tomorrow's date-pinned schedule.
import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook';
// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model.
import { collectBreadth } from '@/lib/marketBreadth';
import MarketBreadth from '@/components/vyndr/MarketBreadth';
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort. // A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
// Grades never change in-game; these marks are tracking, labeled as such. // Grades never change in-game; these marks are tracking, labeled as such.
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress'; import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
@@ -165,7 +172,7 @@ interface StreakApiRow {
interface StreaksResponse { streaks?: StreakApiRow[] } interface StreaksResponse { streaks?: StreakApiRow[] }
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest). // Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number } interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] } interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
@@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) {
); );
} }
// The /api/ledger/model settled-row shape (subset the receipts read).
interface ModelReceiptRow {
player_name?: string; player?: string; sport?: string; stat?: string;
line?: number; side?: string; grade?: string;
outcome?: string | null; actual_value?: number | null; clv_result?: string | null;
}
// buildHeroReceipts output (proven yesterday hit).
interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null }
// mapTomorrowPreview output.
interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null }
/**
* Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in
* place of the old dead-end CTA: yesterday's PROVEN A-tier receipts +
* tomorrow's date-pinned schedule preview (both REAL, always-available data —
* never an invented line). The month-aware header ALWAYS shows, so the grid is
* never blank. Distinct from the network `fetchError` state.
*/
function OutlookSurface({ tab }: { tab: SlateTab }) {
const [settled, setSettled] = useState<ModelReceiptRow[] | null>(null);
const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(null);
useEffect(() => {
let active = true;
const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : '';
fetch(`/api/ledger/model?limit=80${sportQ}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
.catch(() => { if (active) setSettled([]); });
return () => { active = false; };
}, [tab]);
useEffect(() => {
let active = true;
const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned
const SPORTS: SlateSport[] = tab === 'all'
? ['mlb', 'nba', 'wnba']
: (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : [];
if (SPORTS.length === 0) { setTomorrow([]); return; }
Promise.all(SPORTS.map(async (sport) => {
try {
const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' });
if (!r.ok) return [] as ScheduleGame[];
const d = (await r.json()) as ScheduleResponse;
return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport }));
} catch { return [] as ScheduleGame[]; }
})).then((lists) => { if (active) setTomorrow(lists.flat()); });
return () => { active = false; };
}, [tab]);
const outlook = useMemo(
() => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] },
[settled, tomorrow],
);
const receipts: OutlookReceipt[] = outlook.receipts ?? [];
const preview: OutlookGame[] = outlook.tomorrow ?? [];
const { title, body } = emptyStateCopy(tab);
return (
<section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
<div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
<p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
<p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
</div>
{receipts.length > 0 && (
<div style={{ marginBottom: preview.length ? 18 : 0 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
{receipts.map((r, i) => (
<div
key={`${r.player}-${r.stat}-${i}`}
className="mono"
style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
<span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
</div>
<div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
</div>
</div>
))}
</div>
</div>
)}
{preview.length > 0 && (
<div>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}> lines post on the day</span>
</div>
<div style={{ display: 'grid', gap: 7 }}>
{preview.map((g) => (
<div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
{g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
{g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
</div>
))}
</div>
</div>
)}
</section>
);
}
export interface SlateProps { export interface SlateProps {
initialTab?: SlateTab; initialTab?: SlateTab;
tier?: Tier; tier?: Tier;
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
)) as SlateGame[]; )) as SlateGame[];
}, [filteredGames, gradeIndex, liveIndex]); }, [filteredGames, gradeIndex, liveIndex]);
// Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that
// carries ≥2 book lines, compare the market's median line to the model's
// projection (signed by the graded side). collectBreadth drops <2-book props
// and ranks by |edge| — an empty result self-hides the strip. This is the
// REAL data behind the DeskShowcase "consensus vs model" claim.
const breadthItems = useMemo(() => {
const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = [];
for (const g of filteredGames) {
for (const p of g.props) {
if (!Array.isArray(p.books) || p.books.length < 2) continue;
const grade = (gradeIndex as Record<string, SnapshotGrade>)[gradeKey(p.player, p.stat_type)];
const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null;
items.push({
player: p.player,
stat: p.stat_type,
side: (grade && grade.direction) || p.direction || 'over',
line: p.line,
books: p.books,
modelValue: mv == null ? null : Number(mv),
});
}
}
return collectBreadth(items, 6);
}, [filteredGames, gradeIndex]);
// Session 25 — per-sport game counts for the tab labels, derived from // Session 25 — per-sport game counts for the tab labels, derived from
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the // the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
// free ESPN schedule even when odds are empty. Counts only appear for // free ESPN schedule even when odds are empty. Counts only appear for
@@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
</div> </div>
)} )}
{!loading && !fetchError && filteredGames.length === 0 && ( {/* A search miss keeps its own scan-it CTA. */}
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
<div <div
className="surface" className="surface"
style={{ style={{
@@ -976,8 +1122,6 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
color: 'var(--text-secondary, #8A8A9A)', color: 'var(--text-secondary, #8A8A9A)',
}} }}
> >
{searchQuery ? (
<>
<p style={{ marginBottom: 12 }}> <p style={{ marginBottom: 12 }}>
No props found for &ldquo;{searchQuery}&rdquo;. No props found for &ldquo;{searchQuery}&rdquo;.
</p> </p>
@@ -997,11 +1141,29 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
> >
Scan it manually Scan it manually
</a> </a>
</> </div>
) : ( )}
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
// off-season sports name their return window; in-season = off-day. {/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
(() => { when there are no live games (and no search, no fetch failure) it shows
yesterday's proven receipts + tomorrow's real schedule. Yesterday/
Tomorrow date nav keep the plain honest copy (those are explicit date
surfaces; -1 already has THE SETTLE below). */}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
<OutlookSurface tab={tab} />
)}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{(() => {
const { title, body } = emptyStateCopy(tab); const { title, body } = emptyStateCopy(tab);
return ( return (
<> <>
@@ -1009,13 +1171,16 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
<p>{body}</p> <p>{body}</p>
</> </>
); );
})() })()}
)}
</div> </div>
)} )}
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />} {dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
{/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
graded prop has a real ≥2-book median to compare the model against. */}
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
<div style={{ display: 'grid', gap: 16 }}> <div style={{ display: 'grid', gap: 16 }}>
{orderedGames.map((g, i) => ( {orderedGames.map((g, i) => (
<VyndrGameCard <VyndrGameCard
+12 -1
View File
@@ -18,11 +18,12 @@ interface ArchetypeBadgeProps {
*/ */
export default function ArchetypeBadge({ export default function ArchetypeBadge({
archetype, archetype,
sport,
variant = 'tint', variant = 'tint',
size = 'sm', size = 'sm',
showDesc = false, showDesc = false,
}: ArchetypeBadgeProps) { }: ArchetypeBadgeProps) {
const s = badgeStyle(archetype, variant, size); const s = badgeStyle(archetype, variant, size, sport);
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
<span <span
@@ -46,10 +47,20 @@ export default function ArchetypeBadge({
textShadow: s.textShadow, textShadow: s.textShadow,
}} }}
> >
{s.glyphChar ? (
// Combat glyphs are unicode chars (data never glitches — chrome label).
<span
aria-hidden
style={{ display: 'inline-flex', flex: 'none', alignItems: 'center', justifyContent: 'center', width: s.glyphSize, fontSize: s.glyphSize, lineHeight: 1, color: s.glyphColor }}
>
{s.glyphChar}
</span>
) : (
<span <span
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }} style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }} dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
/> />
)}
{s.name} {s.name}
</span> </span>
{showDesc && s.desc && ( {showDesc && s.desc && (
+19 -6
View File
@@ -1,10 +1,16 @@
import { bookInfo } from '@/lib/books'; import { bookInfo, bookSlug, hasBookSvg } from '@/lib/books';
/** /**
* BookWordmark (DS0) — a sportsbook renders as its brand: the real name in * BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a
* the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare * bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8
* lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts * major books) it renders that; otherwise it falls back to the real book NAME
* where the BookChip tile is too heavy. `best` gives it the signal ring. * in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
* lowercase "draftkings" string (DESIGN-SPEC Part 2).
*
* The bundled SVGs are self-authored styled text wordmarks — not copied
* trademarked logo glyphs — so the founder can drop official press-kit art into
* the same paths later with zero code change. The files are local + always
* present, so no broken-image state is possible. `best` gives the signal ring.
*/ */
export default function BookWordmark({ export default function BookWordmark({
book, book,
@@ -16,6 +22,8 @@ export default function BookWordmark({
size?: number; size?: number;
}) { }) {
const b = bookInfo(book); const b = bookInfo(book);
const slug = bookSlug(book);
const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null;
return ( return (
<span <span
className="mono" className="mono"
@@ -29,7 +37,12 @@ export default function BookWordmark({
}} }}
> >
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />} {best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
{b.name} {svg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={svg} alt={b.name} height={Math.round(size * 1.35)} style={{ display: 'block', width: 'auto', height: Math.round(size * 1.35) }} />
) : (
b.name
)}
</span> </span>
); );
} }
+259
View File
@@ -0,0 +1,259 @@
import ArchetypeBadge from './ArchetypeBadge';
import SportBadge from './SportBadge';
import { combatArchetypeColor } from '@/lib/archetypes';
/* ============================================================
FightCard (Wave 6 — combat intelligence, honest v1).
The tale-of-the-tape head-to-head from the design mockup:
FIGHTER A · CENTER VERDICT · FIGHTER B. Two fighters side-by-side
(NOT the player-strip row grammar). All data is MONO and never
glitches. Physicals/records are REAL sourced facts — absent fields
render as "—", never fabricated. No fighter photos (likeness rule):
an initials monogram only. Style blend + verdict are a MODEL read,
explicitly labeled. Method / round / KO are shown as honest
"— data-limited" placeholders (DEFERRED sub-wave), never invented.
============================================================ */
export interface BlendEntry {
archetype: string;
weight: number; // 0-1
}
export interface FighterTape {
id?: string | null;
name: string;
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
stance?: string | null;
reach?: string | number | null;
/** Style blend (MODEL) — absent when the free feed is too thin to profile. */
blend?: BlendEntry[] | null;
/** Verifiable discipline credentials only — absent when unknown, never guessed. */
pedigrees?: string[] | null;
}
export interface FightCardOdds {
moneyline?: { home?: number | null; away?: number | null } | null;
roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null;
}
export interface FightVerdict {
verdict: string; // e.g. "GRAPPLER EDGE" | "STYLES EVEN" | "INSUFFICIENT READ"
edgeSide?: 'a' | 'b' | null;
summary?: string;
}
export interface FightCardProps {
weightClass?: string | null;
rounds?: number | null;
status?: string | null;
fighters: FighterTape[]; // [A, B]
odds?: FightCardOdds | null;
verdict?: FightVerdict | null;
}
const DASH = '—';
const fmtOdds = (v?: number | null) => (typeof v === 'number' && Number.isFinite(v) ? (v > 0 ? `+${v}` : `${v}`) : DASH);
function initials(name: string): string {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/** The two range-axis bars the mockup renders (GRAPPLER% / STRIKER%). */
function topBars(blend?: BlendEntry[] | null): BlendEntry[] {
if (!Array.isArray(blend) || blend.length === 0) return [];
return [...blend].sort((a, b) => b.weight - a.weight).slice(0, 3);
}
function Monogram({ name }: { name: string }) {
return (
<span
aria-hidden
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 40, height: 40, borderRadius: 8, flex: 'none',
background: 'var(--bg-2, #14141E)', border: '1px solid var(--border, #1E1E2A)',
color: 'var(--text-1, #B8BCC8)', fontWeight: 800, fontSize: 14, letterSpacing: '0.04em',
}}
>
{initials(name)}
</span>
);
}
function Fighter({ f, align }: { f: FighterTape; align: 'left' | 'right' }) {
const bars = topBars(f.blend);
const meta: string[] = [];
if (f.record?.display) meta.push(f.record.display);
if (f.stance) meta.push(String(f.stance).toUpperCase());
if (f.reach != null && f.reach !== '') meta.push(`${f.reach}" REACH`);
const primary = bars[0]?.archetype || null;
const rowDir = align === 'right' ? 'row-reverse' : 'row';
const textAlign = align === 'right' ? 'right' : 'left';
return (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: rowDir, alignItems: 'center', gap: 10, marginBottom: 12 }}>
<Monogram name={f.name} />
<div style={{ minWidth: 0, textAlign }}>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-0, #F0F0F0)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{f.name}
</div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginTop: 2 }}>
{meta.length ? meta.join(' · ') : `RECORD ${DASH}`}
</div>
</div>
</div>
{/* Style-blend bars (MODEL) — only when the fighter is profiled. */}
{bars.length > 0 ? (
<div style={{ marginBottom: 12 }}>
{bars.map((b) => {
const c = combatArchetypeColor(b.archetype);
const pct = Math.round((b.weight || 0) * 100);
return (
<div key={b.archetype} style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', color: c }}>
{b.archetype}
</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-1, #B8BCC8)' }}>{pct}%</span>
</div>
<div style={{ height: 5, borderRadius: 3, background: 'var(--bg-2, #14141E)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: c }} />
</div>
</div>
);
})}
</div>
) : (
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginBottom: 12, textAlign }}>
STYLE PROFILE {DASH} DATA-LIMITED
</div>
)}
{primary && (
<div style={{ display: 'flex', flexDirection: rowDir, marginBottom: 8 }}>
<ArchetypeBadge archetype={primary} sport="mma" variant="tint" size="sm" />
</div>
)}
{/* Discipline pedigree tags — verifiable only, absent when unknown. */}
{Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, flexDirection: rowDir === 'row-reverse' ? 'row-reverse' : 'row' }}>
{f.pedigrees.map((p) => (
<span
key={p}
className="mono"
style={{
fontSize: 9, letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
color: 'var(--text-1, #B8BCC8)', border: '1px solid var(--border, #1E1E2A)', background: 'var(--bg-1, #0A0A10)',
}}
>
{p.toUpperCase()}
</span>
))}
</div>
)}
</div>
);
}
function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) {
return (
<div style={{ background: 'var(--bg-1, #0A0A10)', padding: '11px 12px', borderRadius: 6, border: '1px solid var(--border, #1E1E2A)' }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.14em', color: 'var(--text-2, #707080)', marginBottom: 6 }}>
{label}
</div>
{dataLimited ? (
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)' }}>{DASH} data-limited</div>
) : (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1, #B8BCC8)', fontWeight: 700 }}>{value}</div>
)}
</div>
);
}
export default function FightCard({ weightClass, rounds, status, fighters, odds, verdict }: FightCardProps) {
// Self-hide honestly if we don't have a two-fighter bout.
if (!Array.isArray(fighters) || fighters.length < 2 || !fighters[0]?.name || !fighters[1]?.name) return null;
const [a, b] = fighters;
const v = verdict && verdict.verdict ? verdict : { verdict: 'INSUFFICIENT READ', edgeSide: null as null, summary: 'Not enough style data to call this — a MODEL read needs both fighters profiled.' };
const isCall = v.verdict !== 'INSUFFICIENT READ' && v.verdict !== 'STYLES EVEN';
const edgeStyle = isCall ? v.verdict.replace(/\s+EDGE$/i, '') : null;
const verdictColor = edgeStyle ? combatArchetypeColor(edgeStyle) : 'var(--text-2, #707080)';
const ml = odds?.moneyline || null;
const rt = odds?.roundTotal || null;
return (
<section
style={{
border: '1px solid var(--border, #1E1E2A)', borderRadius: 12,
background: 'var(--bg-1, #0A0A10)', padding: 16, maxWidth: 640,
}}
>
{/* Card header — weight class + rounds (mono chrome). */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SportBadge sport="mma" size="sm" />
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>
{[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' · ') || 'BOUT'}
</span>
</div>
{status === 'post' && (
<span className="mono" style={{ fontSize: 9, letterSpacing: '0.1em', color: 'var(--text-2, #707080)' }}>FINAL</span>
)}
</div>
{/* FIGHTER A · VERDICT · FIGHTER B */}
<div className="fight-tape" style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<Fighter f={a} align="left" />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, paddingTop: 8, flex: 'none', width: 96 }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.24em', color: 'var(--text-2, #707080)' }}>VERDICT</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-2, #707080)' }}>VS</div>
<div
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', textAlign: 'center', padding: '3px 8px', borderRadius: 6,
background: `${verdictColor}22`, color: verdictColor, fontWeight: 700, fontSize: 9.5,
border: `1px solid ${verdictColor}55`, lineHeight: 1.3,
}}
>
{v.verdict}
</div>
<div className="mono" style={{ fontSize: 8, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>MODEL READ</div>
</div>
<Fighter f={b} align="right" />
</div>
{v.summary && (
<p className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)', marginTop: 12, lineHeight: 1.5 }}>
{v.summary}
</p>
)}
{/* Odds row — MONEYLINE + round total REAL; method/round/KO data-limited. */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8, marginTop: 16 }}>
<OddsCell label={`MONEYLINE · ${initials(a.name)}`} value={fmtOdds(ml?.home)} />
<OddsCell label={`MONEYLINE · ${initials(b.name)}`} value={fmtOdds(ml?.away)} />
<OddsCell
label={rt?.line != null ? `ROUND TOTAL · O${rt.line}` : 'ROUND TOTAL'}
value={rt ? `${fmtOdds(rt.over)} / ${fmtOdds(rt.under)}` : DASH}
dataLimited={!rt}
/>
<OddsCell label="METHOD · KO / SUB / DEC" dataLimited />
</div>
<p className="mono" style={{ fontSize: 9, color: 'var(--text-2, #707080)', marginTop: 10, lineHeight: 1.5 }}>
Method, round and fighter-prop grades are DATA-LIMITED on the free feed shown as {DASH}, never fabricated.
Odds are REAL book numbers; the style verdict is a MODEL read, not a settled grade.
</p>
</section>
);
}
+7 -2
View File
@@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@
import TeamLogo from '@/components/vyndr/TeamLogo'; import TeamLogo from '@/components/vyndr/TeamLogo';
import { accentColor } from '@/lib/teamMeta'; import { accentColor } from '@/lib/teamMeta';
import { playerHref } from '@/lib/playerHref'; import { playerHref } from '@/lib/playerHref';
import { isPreferredBook } from '@/lib/books'; import { isPreferredBook, bookInfo } from '@/lib/books';
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { nextRunLabelET } from '@/lib/pipelineSchedule';
import { useParlay, legKey } from '@/contexts/ParlayContext'; import { useParlay, legKey } from '@/contexts/ParlayContext';
@@ -37,6 +37,9 @@ export interface GameProp {
export interface PlayerStrip { export interface PlayerStrip {
player: string; player: string;
team: string; team: string;
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype; archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null; lineup?: { status: string; slot?: number } | null;
@@ -301,7 +304,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div> <div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
{g.lines.map((ln, i) => ( {g.lines.map((ln, i) => (
<span key={i} style={{ display: 'contents' }}> <span key={i} style={{ display: 'contents' }}>
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{ln.book}</div> <div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{bookInfo(ln.book).name}</div>
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} /> <LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} /> <LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
<LineCell value={ln.ou} best={ln.bestOU} /> <LineCell value={ln.ou} best={ln.bestOU} />
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
player={ps.player} player={ps.player}
team={ps.team} team={ps.team}
sport={g.sport} sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
archetype={ps.archetype} archetype={ps.archetype}
lineup={ps.lineup} lineup={ps.lineup}
injury={ps.injury} injury={ps.injury}
+25 -1
View File
@@ -6,6 +6,9 @@ import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn'; import VBtn from '@/components/vyndr/VBtn';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
import GradeShift from '@/components/vyndr/GradeShift';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { edgeColor, gradeGlows } from '@/lib/colorContract';
import { playerHref } from '@/lib/playerHref'; import { playerHref } from '@/lib/playerHref';
@@ -14,6 +17,10 @@ export interface GradeResultData {
player: string; player: string;
team: string; team: string;
sport: string; sport: string;
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
playerId?: string | number | null;
espnId?: string | number | null;
stat: string; stat: string;
line: number; line: number;
side: 'Over' | 'Under'; side: 'Over' | 'Under';
@@ -33,6 +40,12 @@ export interface GradeResultData {
propDNA?: { reliable: string[]; volatile: string[] }; propDNA?: { reliable: string[]; volatile: string[] };
statContext?: { season?: string; last10?: string; vsOpp?: string }; statContext?: { season?: string; last10?: string; vsOpp?: string };
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string }; vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
// Wave 4B — LIVE GRADE-SHIFT timeline (all optional; GradeShift self-hides
// below 3 real captured points). Fed by the snapshot pipeline's already-
// emitted line history + public revision; the scan path leaves them absent.
history?: Array<{ t: string; line: number }> | null;
revisedFrom?: string | null;
gradedLine?: number | null;
} }
interface GradeResultCardProps { interface GradeResultCardProps {
@@ -96,13 +109,18 @@ export default function GradeResultCard({
{/* 1. HEADER — player name links to the full profile (Session 42) */} {/* 1. HEADER — player name links to the full profile (Session 42) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
{/* Wave 2A — player identity block: real headshot / team-colored
monogram (never a gray silhouette). */}
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
<div style={{ minWidth: 0 }}>
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a> <a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}> <div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span> <span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat} <span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
</div> </div>
</div> </div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<SportBadge sport={d.sport} /> <SportBadge sport={d.sport} />
{d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>} {d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>}
@@ -187,6 +205,12 @@ export default function GradeResultCard({
))} ))}
</div> </div>
{/* 4b. LIVE GRADE-SHIFT (Wave 4B) — the line/grade movement timeline over
the snapshot pipeline's already-emitted history. Self-hides below 3
real captured points, so the scan path (no captured history) shows
nothing rather than a fabricated timeline. */}
<GradeShift history={d.history} side={d.side} grade={d.grade} revisedFrom={d.revisedFrom} gradedLine={d.gradedLine ?? d.line} />
{/* 5. SIGNAL BREAKDOWN */} {/* 5. SIGNAL BREAKDOWN */}
{d.signals.length > 0 && ( {d.signals.length > 0 && (
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { buildGradeTimeline } from "@/lib/gradeShift";
/**
* GradeShift (Wave 4B) — the LIVE GRADE-SHIFT / GRADE HISTORY · LAST 24 view.
* A timeline VIEW over ALREADY-EMITTED data: the intraday line-history points
* ({t, line}) + any public revision (revised_from_grade). No new backend.
*
* Doctrine:
* - Self-hides below 3 real history points (buildGradeTimeline.show).
* - Color law (ROW-GRAMMAR): green = toward the graded side, amber = against,
* dim = flat. NEVER red — nothing here is settled.
* - A revision shows the ORIGINAL grade struck-through (never silently dropped).
* - Data is mono; nothing glitches.
*/
export interface GradeShiftHistoryPoint {
t: string;
line: number;
}
interface GradeShiftProps {
history?: Array<GradeShiftHistoryPoint> | null;
side?: string;
grade?: string | null;
revisedFrom?: string | null;
gradedLine?: number | null;
}
export default function GradeShift({ history, side, grade, revisedFrom, gradedLine }: GradeShiftProps) {
const tl = buildGradeTimeline({ history, side, grade, revisedFrom, gradedLine });
if (!tl.show) return null; // honest self-hide — no fabricated timeline
const lines = tl.points.map((p) => p.line);
const min = Math.min(...lines);
const max = Math.max(...lines);
const rng = max - min || 1;
const netSign = tl.net.delta > 0 ? "+" : "";
return (
<div
style={{
margin: "0 20px 16px",
padding: "14px 16px",
borderRadius: 12,
border: "1px solid var(--border)",
background: "var(--bg-2)",
}}
aria-label="Line history since lock"
>
{/* header + legend */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.16em", color: "var(--text-1)" }}>
GRADE HISTORY · LAST 24
</span>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: tl.net.color, letterSpacing: "0.04em" }}>
{tl.net.dir === "toward" ? "TOWARD" : tl.net.dir === "against" ? "AGAINST" : "FLAT"} {netSign}{tl.net.delta}
</span>
</div>
{/* revision — original grade struck-through, never silently */}
{tl.revision && (
<div className="mono" style={{ fontSize: 11, color: "var(--text-2)", marginBottom: 10, display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ letterSpacing: "0.08em" }}>REVISED</span>
<span style={{ textDecoration: "line-through", color: "var(--text-2)" }}>{tl.revision.from}</span>
<span style={{ color: "var(--text-1)" }}></span>
<span style={{ color: "var(--text-0)", fontWeight: 700 }}>{tl.revision.to}</span>
</div>
)}
{/* bars — one per capture, height by relative line, colored by segment dir */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 44 }}>
{tl.points.map((p, i) => {
const h = 30 + ((p.line - min) / rng) * 70; // 30%..100%
const isGreen = p.color === "var(--g-a)";
const isAmber = p.color === "var(--amber)";
const bg = isGreen
? "var(--g-a)"
: isAmber
? "var(--amber)"
: "var(--border-hi)";
return (
<div
key={i}
title={`${p.line}${p.delta ? ` (${p.delta > 0 ? "+" : ""}${p.delta})` : ""}`}
style={{
flex: 1,
height: `${h}%`,
borderRadius: "3px 3px 0 0",
background: bg,
opacity: p.dir === "flat" ? 0.55 : 0.9,
}}
/>
);
})}
</div>
{/* clock rail — lock -> now, mirrors the mockup's TIP/NOW */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 6 }}>
<span className="mono" style={{ fontSize: 8.5, color: "var(--text-2)", letterSpacing: "0.1em" }}>
LOCK {tl.firstLine}
</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--text-2)", letterSpacing: "0.1em" }}>
NOW {tl.lastLine}
</span>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
/**
* MarketBreadth (Wave 4A · Step 4) — the CONSENSUS-vs-MODEL strip that makes
* the DeskShowcase "consensus vs model, live line moves" claim REAL.
*
* Fed pre-computed breadth rows (from `lib/marketBreadth.collectBreadth`): each
* row carries the market CONSENSUS (median book line across ≥2 books) and the
* model's position vs it, SIGNED by the graded side. Colored by the ONE color
* contract (`edgeColor`): model-beats-market → signal-green, model-behind →
* muted red, inline → neutral. Mono, tabular — it's data.
*
* SELF-HIDING: no rows (nothing has ≥2 books) → renders null. It never shows an
* empty promise; the honest surface only appears when there's a real consensus.
*/
import SectionHead from './SectionHead';
import { edgeColor } from '@/lib/colorContract';
export interface BreadthRow {
player?: string;
stat?: string;
line?: number | null;
consensus: number | null;
bookCount: number;
model: number | null;
delta: number | null;
signedEdge: number | null;
// Widened to string|null: collectBreadth is untyped JS, so its inferred
// return widens these; the values are only ever 'above'|'below'|'inline'.
position: string | null;
side: string;
}
const STAT_LABEL: Record<string, string> = {
total_bases: 'TB', home_runs: 'HR', hits: 'H', rbi: 'RBI', runs: 'R',
strikeouts: 'K', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
stolen_bases: 'SB', points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT',
};
function statLabel(stat?: string): string {
if (!stat) return '';
return STAT_LABEL[stat] || String(stat).replace(/_/g, ' ').toUpperCase();
}
function fmtSigned(n: number | null): string {
if (n == null) return '';
const s = n > 0 ? '+' : n < 0 ? '' : '±';
return `${s}${n}`;
}
export default function MarketBreadth({
items,
title = 'CONSENSUS vs MODEL',
max = 6,
}: {
items?: BreadthRow[] | null;
title?: string;
max?: number;
}) {
const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max));
if (rows.length === 0) return null; // self-hide — no honest consensus to show
return (
<section
style={{
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderRadius: 10,
padding: 16,
marginBottom: 16,
}}
>
<SectionHead style={{ marginBottom: 12 }}>
{title}
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 9, marginLeft: 8, letterSpacing: '0.14em' }}>
MEDIAN BOOK LINE · MODEL EDGE
</span>
</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r, i) => {
const col = edgeColor(r.signedEdge);
const sideChar = r.side === 'under' ? 'u' : 'o';
return (
<div
key={`${r.player}-${r.stat}-${i}`}
className="mono"
style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap', fontVariantNumeric: 'tabular-nums' }}
>
{r.player && <span style={{ color: 'var(--text-0)', fontWeight: 700, minWidth: 96 }}>{r.player}</span>}
<span style={{ color: 'var(--text-1)' }}>
{statLabel(r.stat)} {sideChar}{r.line ?? r.consensus}
</span>
<span style={{ color: 'var(--text-2)' }}>
CONSENSUS <span style={{ color: 'var(--text-0)' }}>{r.consensus}</span>
<span style={{ color: 'var(--text-2)' }}> · {r.bookCount} BOOKS</span>
</span>
{r.model != null ? (
<span style={{ color: 'var(--text-2)' }}>
MODEL <span style={{ color: 'var(--text-0)' }}>{r.model}</span>
{r.signedEdge != null && (
<span style={{ color: col, fontWeight: 800 }}> · {fmtSigned(r.signedEdge)}</span>
)}
</span>
) : (
<span style={{ color: 'var(--text-2)' }}>MODEL </span>
)}
</div>
);
})}
</div>
</section>
);
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useState } from 'react';
/**
* PitcherArsenal (Wave 5B) — the mockup's PITCHER IDENTITY lens: pitch mix % +
* velo + whiff%, mono/tabular, ranked by usage. Fed by the Baseball Savant
* arsenal adapter (FREE Statcast). "ONE IDENTITY → MANY PROPS".
*
* HONESTY: arsenal is CONTEXT (the read), never a graded market value. The card
* SELF-HIDES (returns null) when Savant has no arsenal for the pitcher — no empty
* box, no fabricated numbers. A missing velo/whiff renders as "—" (absent), NOT 0.
*
* Two modes:
* • pass `arsenal` (already fetched by a parent) — renders synchronously.
* • pass `name` (+ optional pitcher meta) — fetches /api/stats/pitcher/:name/arsenal.
*/
export interface ArsenalPitch {
type: string; // FF / SL / FS / CU …
name: string; // "4-Seam Fastball"
usagePct: number | null;
velo: number | null;
whiffPct: number | null;
kPct?: number | null;
}
export interface PitcherArsenalData {
found: boolean;
playerId?: number | string;
pitches?: ArsenalPitch[];
}
export interface PitcherMeta {
name?: string;
hand?: string; // RHP / LHP
number?: string | number; // jersey
team?: string;
vs?: string; // opponent abbr
confirmed?: boolean; // probable-pitcher confirmed
}
interface Props {
arsenal?: PitcherArsenalData | null;
name?: string; // fetch by name when arsenal not supplied
sport?: string; // default 'mlb'
pitcher?: PitcherMeta; // optional identity header
heading?: boolean; // render the "PITCHER IDENTITY" label above the card
}
// Pitch color dots (Statcast-flavored, matching the mockup). Data chrome — no glitch.
const PITCH_COLOR: Record<string, string> = {
FF: '#FF7A5A', FA: '#FF7A5A', // 4-seam / fastball
SI: '#E0803D', FT: '#E0803D', FS: '#E0803D', FO: '#E0803D', // sinker / two-seam / splitter
FC: '#E8A33D', // cutter
SL: '#7C5CFF', ST: '#7C5CFF', SV: '#9B7CFF', // slider / sweeper / slurve
CU: '#6C9CB0', KC: '#6C9CB0', CS: '#6C9CB0', // curveballs
CH: '#4FB0A0', SC: '#4FB0A0', // change / screw
KN: '#8888A0', EP: '#8888A0', // knuckle / eephus
};
function pitchColor(type: string) {
return PITCH_COLOR[String(type || '').toUpperCase()] || '#6C9CB0';
}
// Absent beats zero — a null velo/whiff/usage renders as an em-dash, never 0.
function fmtVelo(v: number | null | undefined) {
return v === null || v === undefined ? '—' : v.toFixed(1);
}
function fmtPct(v: number | null | undefined) {
return v === null || v === undefined ? '—' : `${Math.round(v)}%`;
}
const COL = '1fr 56px 46px 52px';
export default function PitcherArsenal({ arsenal, name, sport = 'mlb', pitcher, heading }: Props) {
const [fetched, setFetched] = useState<PitcherArsenalData | null>(arsenal ?? null);
useEffect(() => {
if (arsenal !== undefined && arsenal !== null) { setFetched(arsenal); return; }
if (!name) return;
let alive = true;
fetch(`/api/stats/pitcher/${encodeURIComponent(name)}/arsenal?sport=${encodeURIComponent(sport)}`, {
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((d) => { if (alive) setFetched(d && typeof d === 'object' ? d : null); })
.catch(() => { if (alive) setFetched(null); });
return () => { alive = false; };
}, [arsenal, name, sport]);
const pitches = fetched && fetched.found && Array.isArray(fetched.pitches) ? fetched.pitches : [];
// SELF-HIDE: no real arsenal → render nothing (never an empty shell).
if (pitches.length === 0) return null;
// Highlight the sharpest whiff pitch(es) green — the "what misses bats" read.
const maxWhiff = Math.max(...pitches.map((p) => (p.whiffPct ?? -1)));
const isSharp = (p: ArsenalPitch) => p.whiffPct !== null && p.whiffPct !== undefined && p.whiffPct >= 30 && p.whiffPct >= maxWhiff - 3;
const meta = pitcher || {};
const monogram = (meta.team || (meta.name || '').slice(0, 3) || 'PIT').toString().slice(0, 3).toUpperCase();
const Card = (
<div style={{ borderRadius: 18, background: 'linear-gradient(180deg,#0F0F17,#0A0A10)', border: '1px solid #2A2A38', overflow: 'hidden', boxShadow: '0 30px 70px -40px rgba(0,0,0,.9), inset 0 1px 0 rgba(255,255,255,.04)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1.35fr)', alignItems: 'stretch' }} className="parsenal-grid">
{/* identity */}
<div style={{ padding: '20px 22px', borderRight: '1px solid #14141E' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 8 }}>
<div className="mono" style={{ width: 52, height: 52, flex: 'none', borderRadius: 12, background: 'linear-gradient(135deg,#2b2b2b,#c9a227)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 14, color: '#fff' }}>{monogram}</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 16 }}>{meta.name || 'Probable Pitcher'}</span>
{meta.confirmed && (
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', height: 16, padding: '0 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--g-a) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--g-a) 30%, transparent)', color: 'var(--g-a)', fontSize: 8.5, fontWeight: 700, letterSpacing: '.06em' }}>CONFIRMED</span>
)}
</div>
<div className="mono" style={{ fontSize: 10.5, color: '#707080', marginTop: 3, letterSpacing: '.05em' }}>
{[meta.hand, meta.number != null ? `#${meta.number}` : null, meta.team, meta.vs ? `vs ${meta.vs}` : null].filter(Boolean).join(' · ') || 'ARSENAL'}
</div>
</div>
</div>
<div style={{ marginTop: 14, padding: '11px 13px', borderRadius: 10, background: '#08080D', border: '1px solid #14141E' }}>
<div className="mono" style={{ fontSize: 9, letterSpacing: '.2em', color: '#707080', marginBottom: 6 }}>THE ARSENAL READ</div>
<p style={{ fontFamily: 'var(--sans)', fontSize: 11.5, lineHeight: 1.5, color: '#B8BCC8', margin: 0 }}>
{(() => {
const top = pitches[0];
const sharp = pitches.find(isSharp);
if (sharp && top && sharp.type !== top.type) {
return `${top.name} sets it up; the ${sharp.name.toLowerCase()} is the swing-and-miss pitch (${fmtPct(sharp.whiffPct)} whiff).`;
}
if (top) return `${top.name}-led mix — ${fmtPct(top.usagePct)} usage. One identity, many props.`;
return 'Pitch mix read. One identity, many props.';
})()}
</p>
</div>
</div>
{/* arsenal table */}
<div style={{ padding: '20px 22px' }}>
<div style={{ display: 'grid', gridTemplateColumns: COL, gap: 8, paddingBottom: 9, borderBottom: '1px solid #14141E' }}>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58' }}>PITCH</span>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>VELO</span>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>USE</span>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>WHIFF</span>
</div>
{pitches.map((p, i) => {
const sharp = isSharp(p);
return (
<div key={`${p.type}-${i}`} style={{ display: 'grid', gridTemplateColumns: COL, gap: 8, alignItems: 'center', padding: '8px 0', borderBottom: i < pitches.length - 1 ? '1px solid #101018' : 'none' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 6, height: 6, flex: 'none', borderRadius: 2, background: pitchColor(p.type) }} />
<span style={{ fontFamily: 'var(--sans)', fontSize: 12.5, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</span>
<span className="mono" style={{ fontSize: 12, color: '#F0F0F0', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtVelo(p.velo)}</span>
<span className="mono" style={{ fontSize: 11, color: '#B8BCC8', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtPct(p.usagePct)}</span>
<span className="mono" style={{ fontSize: sharp ? 12 : 11, fontWeight: sharp ? 700 : 400, color: sharp ? 'var(--g-a)' : '#B8BCC8', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtPct(p.whiffPct)}</span>
</div>
);
})}
<div className="mono" style={{ fontSize: 8, letterSpacing: '.12em', color: '#4a4a58', marginTop: 12, textAlign: 'right' }}>SOURCE · BASEBALL SAVANT (STATCAST)</div>
</div>
</div>
</div>
);
if (!heading) return Card;
// Heading lives INSIDE the self-hide guard (past the early `return null`), so an
// absent arsenal drops the label too — never an orphan header.
return (
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1, flex: 'none' }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.1em', color: 'var(--g-a)' }}>PITCHER IDENTITY</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>ONE IDENTITY MANY PROPS</span>
</div>
{Card}
</div>
);
}
+10 -1
View File
@@ -21,17 +21,26 @@ export default function PlayerAvatar({
name, name,
sport = 'mlb', sport = 'mlb',
playerId, playerId,
espnId,
team, team,
size = 36, size = 36,
}: { }: {
name: string; name: string;
sport?: HeadshotSport; sport?: HeadshotSport;
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
playerId?: string | number | null; playerId?: string | number | null;
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
team?: string | null; team?: string | null;
size?: number; size?: number;
}) { }) {
const [broken, setBroken] = useState(false); const [broken, setBroken] = useState(false);
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null; // Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
// all → null → team-colored monogram (never a gray silhouette).
const url = (playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF'; const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken; const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+22 -2
View File
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { playerHref } from '@/lib/playerHref'; import { playerHref } from '@/lib/playerHref';
import { searchTeams, teamHref } from '@/lib/teams'; import { searchTeams, teamHref } from '@/lib/teams';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
/** /**
* SearchModal — S6 (A1 board). Global search over players + teams. * SearchModal — S6 (A1 board). Global search over players + teams.
@@ -31,6 +33,8 @@ interface ResultItem {
sub: string; // team / abbr context sub: string; // team / abbr context
sport: string; sport: string;
href: string; href: string;
// Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic).
playerId?: string | number | null;
} }
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) { export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
const seen = new Set<string>(); const seen = new Set<string>();
const merged: ResultItem[] = []; const merged: ResultItem[] = [];
for (const res of responses) { for (const res of responses) {
for (const p of res.players as Array<{ full_name?: string; team?: string }>) { for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) {
const name = String(p.full_name || '').trim(); const name = String(p.full_name || '').trim();
if (!name) continue; if (!name) continue;
const key = `${name.toLowerCase()}|${res.sport}`; const key = `${name.toLowerCase()}|${res.sport}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
// MLB's search id is the real MLBAM id → headshot. Other sports'
// ids are synthetic → guarded out → monogram.
const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null;
merged.push({ merged.push({
kind: 'player', kind: 'player',
label: name, label: name,
sub: p.team ? String(p.team) : '', sub: p.team ? String(p.team) : '',
sport: res.sport, sport: res.sport,
href: playerHref(name, res.sport), href: playerHref(name, res.sport),
playerId,
}); });
if (merged.length >= 9) break; if (merged.length >= 9) break;
} }
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
fontSize: 13, fontSize: 13,
}} }}
> >
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */} {/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds
the player identity avatar (real headshot / branded monogram). */}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
{it.kind === 'player' && (
<PlayerAvatar
name={it.label}
sport={it.sport as HeadshotSport}
playerId={it.playerId}
team={it.sub || null}
size={26}
/>
)}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span> <span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>} {it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}> <span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
+7 -1
View File
@@ -195,6 +195,10 @@ interface StatStripProps {
player: string; player: string;
team: string; team: string;
sport?: string; sport?: string;
// Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype; archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire). // Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null; lineup?: { status: string; slot?: number } | null;
@@ -225,6 +229,8 @@ export default function StatStrip({
player, player,
team, team,
sport, sport,
playerId,
espnId,
archetype, archetype,
lineup, lineup,
injury, injury,
@@ -378,7 +384,7 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram {/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */} (never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} team={team} size={30} /> <PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName> <PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span> <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />} {archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+176
View File
@@ -0,0 +1,176 @@
'use client';
import { useEffect, useState } from 'react';
import { buildTierRows } from '@/lib/tierRecord';
/**
* TierRecord (Wave 3, Addition 2) — the ONE shared record-by-grade-tier
* table. Rendered identically on the dashboard, the /u public profile, and
* the ledger, so the calibration story is told the same way everywhere.
*
* The tier gradient IS the credibility: A+ hits more than A, A more than B.
* Each tier shows its W-L record ALWAYS (honest even at small n); the hit-%
* only appears when the server already cleared the n≥20 gate (`hit_pct !=
* null`) — below it, "RECORD BUILDING · N settled". The gate lives in
* getModelAggregate; this component never re-derives it.
*
* COLOR CONTRACT: A-tier (A+/A) earns the green edge color; everything below
* A stays neutral. Red is reserved for settled-negative outcomes — never a
* tier label.
*
* TWO MODES:
* • presentational — pass `byTier` (ledger + /u already hold the aggregate).
* • self-fetch — pass `sport?` (dashboard) → reads /api/ledger/model.
* All three surfaces render THIS component (the class fix).
*/
interface TierBucket {
settled: number;
hits: number;
misses: number;
pushes?: number;
hit_pct: number | null;
}
export default function TierRecord({
byTier,
minSample = 20,
sport,
title = 'RECORD BY GRADE',
dense = false,
}: {
/** Presentational mode: the getModelAggregate().by_tier map. */
byTier?: Record<string, TierBucket> | null;
minSample?: number;
/** Self-fetch mode (no byTier): scope the /api/ledger/model read. */
sport?: string;
title?: string;
/** Compact layout for tighter surfaces (dashboard). */
dense?: boolean;
}) {
const [fetched, setFetched] = useState<{ by_tier?: Record<string, TierBucket>; min_sample?: number } | null>(null);
useEffect(() => {
if (byTier) return; // presentational — nothing to fetch
let active = true;
const params = new URLSearchParams({ limit: '1' });
if (sport) params.set('sport', sport.toLowerCase());
fetch(`/api/ledger/model?${params}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!active || !data?.aggregate) return;
setFetched({ by_tier: data.aggregate.by_tier, min_sample: data.min_sample });
})
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [byTier, sport]);
const tiers = byTier || fetched?.by_tier || {};
const ms = byTier ? minSample : Number(fetched?.min_sample) > 0 ? Number(fetched?.min_sample) : minSample;
const rows = buildTierRows(tiers, ms);
if (rows.length === 0) return null; // no settled reads in any tier yet
return (
<div
className="mono"
style={{
marginTop: dense ? 0 : 14,
paddingTop: dense ? 0 : 12,
borderTop: dense ? 'none' : '1px solid var(--border)',
}}
>
<div
style={{
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.14em',
color: 'var(--text-tertiary, #707080)',
marginBottom: 10,
}}
>
{title} · CALIBRATION
</div>
<div
role="table"
aria-label="Model hit rate by grade tier"
style={{
display: 'grid',
gridTemplateColumns: dense
? 'repeat(auto-fit, minmax(120px, 1fr))'
: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: dense ? 8 : 10,
}}
>
{rows.map((r) => (
<div
key={r.tier}
role="row"
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: dense ? '10px 12px' : '12px 14px',
borderRadius: 8,
border: `1px solid ${r.edge ? 'var(--g-a, #00D4A0)' : 'var(--border-hi, #2A2A38)'}`,
// GLOW RESERVED FOR A-TIER ONLY — a faint green wash on A-tier,
// flat surface below. No red on any tier label.
background: r.edge
? 'color-mix(in srgb, var(--g-a, #00D4A0) 8%, transparent)'
: 'var(--bg-surface, #0C0C12)',
}}
>
{/* Tier label — green only for A-tier (the edge). */}
<span
style={{
fontSize: dense ? 13 : 15,
fontWeight: 800,
letterSpacing: '0.04em',
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.tier}-TIER
</span>
{/* W-L record — ALWAYS shown, honest at any n. */}
<span
style={{
fontSize: dense ? 15 : 17,
fontWeight: 800,
letterSpacing: '0.02em',
color: 'var(--text-primary, #F0F0F0)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.record}
</span>
{/* Hit-% ONLY past the n≥20 gate; else building copy. */}
{r.ready ? (
<span
style={{
fontSize: dense ? 11 : 12,
fontWeight: 700,
letterSpacing: '0.06em',
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.hitPct}% HIT
</span>
) : (
<span
style={{
fontSize: dense ? 9.5 : 10.5,
fontWeight: 700,
letterSpacing: '0.06em',
color: 'var(--amber, #FFB347)',
}}
>
RECORD BUILDING · {r.settled} settled
</span>
)}
</div>
))}
</div>
</div>
);
}
+6
View File
@@ -9,6 +9,7 @@ export { default as Card } from './Card';
export { default as Sparkline } from './Sparkline'; export { default as Sparkline } from './Sparkline';
export { default as Ticker } from './Ticker'; export { default as Ticker } from './Ticker';
export { default as EmptyState } from './EmptyState'; export { default as EmptyState } from './EmptyState';
export { default as MarketBreadth } from './MarketBreadth';
export type { EmptyStateProps, EmptyStateAction } from './EmptyState'; export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
export { default as GradeResultCard } from './GradeResultCard'; export { default as GradeResultCard } from './GradeResultCard';
export type { GradeResultData } from './GradeResultCard'; export type { GradeResultData } from './GradeResultCard';
@@ -18,13 +19,18 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f
export { default as ClaimMeter } from './ClaimMeter'; export { default as ClaimMeter } from './ClaimMeter';
export { default as AccuracyBadge } from './AccuracyBadge'; export { default as AccuracyBadge } from './AccuracyBadge';
export { default as ModelRecord } from './ModelRecord'; export { default as ModelRecord } from './ModelRecord';
export { default as TierRecord } from './TierRecord';
/* Player Intelligence (Session 42) */ /* Player Intelligence (Session 42) */
export { default as ArchetypeBadge } from './ArchetypeBadge'; export { default as ArchetypeBadge } from './ArchetypeBadge';
export { default as FightCard } from './FightCard';
export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard';
export { default as ArchetypeBlend } from './ArchetypeBlend'; export { default as ArchetypeBlend } from './ArchetypeBlend';
export type { BlendSegment } from './ArchetypeBlend'; export type { BlendSegment } from './ArchetypeBlend';
export { default as StatStrip } from './StatStrip'; export { default as StatStrip } from './StatStrip';
export type { StatCell, StripProp, StripArchetype } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip';
export { default as PitcherArsenal } from './PitcherArsenal';
export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal';
export { default as BookChip } from './BookChip'; export { default as BookChip } from './BookChip';
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */ /* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
+3 -1
View File
@@ -22,7 +22,9 @@ export const SPORTS: Record<SportKey, SportConfig> = {
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, // Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
// tale-of-the-tape). Not in the graded-props pipeline yet → collectData false.
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
}; };
+37 -6
View File
@@ -84,6 +84,21 @@ const ARCHETYPE_MAP = {
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
}; };
/* Combat archetype visual map (Wave 6 — MMA/UFC). SEPARATE from ARCHETYPE_MAP:
FINISHER's combat name/color/glyph differ from the soccer FINISHER, and
combat FINISHER's green is intentionally close to the pitch-green here. Keys/
colors/glyph CHARS MUST match src/services/archetypeService.js
COMBAT_ARCHETYPES — tests/unit/combatArchetypes.test.js asserts it.
`char` is a unicode glyph (rendered as text, not an SVG glyph key). */
const COMBAT_ARCHETYPE_MAP = {
STRIKER: { c: '#E8703A', d: 'Wins on the feet — volume + power at range.', char: '✦', axis: 'range' },
GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms — control + subs.', char: '⊗', axis: 'range' },
PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➤', axis: 'tempo' },
COUNTER: { c: '#8E7BE0', d: 'Patient — punishes what you show him.', char: '◊', axis: 'tempo' },
FINISHER: { c: '#12B886', d: 'Ends nights — high KO/SUB rate.', char: '▲', axis: 'outcome' },
GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: '▦', axis: 'outcome' },
};
const FALLBACK = { c: '#9499A8', d: '', g: '' }; const FALLBACK = { c: '#9499A8', d: '', g: '' };
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to // Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
@@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k; if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
} }
function archetypeInfo(name) { function archetypeInfo(name, sport) {
const key = (name == null ? '' : String(name)).toUpperCase(); const key = (name == null ? '' : String(name)).toUpperCase();
// Combat archetypes live in their own namespace (FINISHER collides with the
// soccer archetype) — resolve them ONLY when the sport is MMA.
if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) {
return COMBAT_ARCHETYPE_MAP[key];
}
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key]; if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]]; if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
return FALLBACK; return FALLBACK;
} }
function archetypeColor(name) { function archetypeColor(name, sport) {
return archetypeInfo(name).c; return archetypeInfo(name, sport).c;
}
/** Combat-only color lookup (unambiguous — no soccer FINISHER collision). */
function combatArchetypeColor(name) {
const key = (name == null ? '' : String(name)).toUpperCase();
return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c;
} }
function glyphSvg(glyphKey) { function glyphSvg(glyphKey) {
@@ -114,8 +140,8 @@ function glyphSvg(glyphKey) {
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default). * variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
* size: 'sm' | 'md'. * size: 'sm' | 'md'.
*/ */
function badgeStyle(name, variant = 'tint', size = 'sm') { function badgeStyle(name, variant = 'tint', size = 'sm', sport) {
const info = archetypeInfo(name); const info = archetypeInfo(name, sport);
const sm = size === 'sm'; const sm = size === 'sm';
let textColor, bg, borderColor, glyphColor, textShadow = 'none'; let textColor, bg, borderColor, glyphColor, textShadow = 'none';
if (variant === 'full' || variant === 'solid') { if (variant === 'full' || variant === 'solid') {
@@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
} }
// Display the canonical VYNDR name even if a legacy name was passed. // Display the canonical VYNDR name even if a legacy name was passed.
const upper = (name == null ? '' : String(name)).toUpperCase(); const upper = (name == null ? '' : String(name)).toUpperCase();
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper];
const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
return { return {
name: canonical, name: canonical,
desc: info.d, desc: info.d,
glyph: info.g, glyph: info.g,
// Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys).
glyphChar: isCombat ? info.char : null,
color: info.c, color: info.c,
textColor, bg, borderColor, glyphColor, textShadow, textColor, bg, borderColor, glyphColor, textShadow,
fontSize: sm ? '9.5px' : '12px', fontSize: sm ? '9.5px' : '12px',
@@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
module.exports = { module.exports = {
GLYPHS, GLYPHS,
ARCHETYPE_MAP, ARCHETYPE_MAP,
COMBAT_ARCHETYPE_MAP,
archetypeInfo, archetypeInfo,
archetypeColor, archetypeColor,
combatArchetypeColor,
glyphSvg, glyphSvg,
badgeStyle, badgeStyle,
}; };
+58
View File
@@ -0,0 +1,58 @@
/* ============================================================
VYNDR — BILLING DISPLAY (honest renewal render).
Plain CommonJS so .tsx components import it AND the Jest suite
requires it directly (same pattern as colorContract.js / checkout.js).
The lie this kills: the profile page rendered `subscription_end`
verbatim, so a manually-seeded / comped founder row reading
"6/9/2036" showed as a real renewal. VYNDR tiers are MONTHLY only
(no annual), so any date more than ~60 days out is NOT a plausible
monthly next-bill — it is a comped / lifetime / seed value and must
NOT be rendered as a renewal date.
Doctrine: absent-but-honest beats wrong-but-full. Never render a
renewal date the billing cadence can't justify. Strict parsing
(the `Number(null) === 0` class of bug) — an unparseable value is
`unknown`, never coerced to an epoch date.
============================================================ */
// A monthly plan renews ~30 days out; allow slack for proration / grace,
// but a value beyond this many days out cannot be a monthly renewal.
const MONTHLY_RENEWAL_MAX_DAYS = 60;
// A renewal more than this many days in the PAST is a lapsed subscription
// (a small grace window absorbs clock skew / just-past renewals).
const LAPSED_GRACE_DAYS = 2;
const DAY_MS = 86_400_000;
/**
* classifyRenewal(subscriptionEnd, nowMs) → { kind, iso? }
*
* kind ∈
* 'date' — a plausible monthly renewal (0..~60d out). Carries `iso`
* (the parsed timestamp) for the caller to localize.
* 'none' — more than ~60d out → comped / lifetime / seed value; there
* is no scheduled monthly renewal to show.
* 'lapsed' — more than 2d past → the subscription window has ended.
* 'unknown' — absent / empty / unparseable → show an em dash.
*
* @param {string|number|null|undefined} subscriptionEnd provider-asserted end.
* @param {number} [nowMs] current epoch ms (injectable for tests).
*/
function classifyRenewal(subscriptionEnd, nowMs = Date.now()) {
if (subscriptionEnd === null || subscriptionEnd === undefined || subscriptionEnd === '') {
return { kind: 'unknown' };
}
const t = Date.parse(subscriptionEnd);
if (Number.isNaN(t)) return { kind: 'unknown' };
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
const diffDays = (t - now) / DAY_MS;
if (diffDays < -LAPSED_GRACE_DAYS) return { kind: 'lapsed' };
if (diffDays > MONTHLY_RENEWAL_MAX_DAYS) return { kind: 'none' };
return { kind: 'date', iso: new Date(t).toISOString() };
}
module.exports = { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS };
+55 -15
View File
@@ -1,29 +1,69 @@
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html /* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */ BOOKS table. CommonJS so it's testable + importable from the .tsx chip.
Wave 2B (data train) — the map now covers EVERY key the odds feed emits
(`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars,
fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book
falls to the neutral-gray default. Entries carry a `slug` = the canonical
lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and
BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under
`web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for
official press-kit art without a code change). */
const BOOKS = { const BOOKS = {
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, 'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' }, // BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' }, FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' },
PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' },
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' }, UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
}; };
// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg).
// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs.
const BUNDLED_BOOK_SVGS = new Set([
'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers',
]);
const DEFAULT_FG = '#B8BCC8';
function bookInfo(book) { function bookInfo(book) {
const key = String(book == null ? '' : book).toUpperCase(); const key = String(book == null ? '' : book).toUpperCase();
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' }; return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' };
}
/** Canonical lowercase book slug for any input (id/code/name), or null when the
* book isn't in the registry. Used to look up the bundled wordmark SVG. */
function bookSlug(book) {
const info = bookInfo(book);
if (info.slug) return info.slug;
// Unknown-but-nameable: derive a slug from the resolved name (still not the
// raw lowercase feed key). Only bundled slugs matter for the SVG lookup.
return null;
}
/** Does `book` have a bundled local wordmark SVG? */
function hasBookSvg(book) {
const slug = bookSlug(book);
return slug != null && BUNDLED_BOOK_SVGS.has(slug);
} }
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings" /** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
@@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) {
return preferred.some((p) => bookKey(p) === k); return preferred.some((p) => bookKey(p) === k);
} }
module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook }; module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook };
+3
View File
@@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) {
player: input.player || '', player: input.player || '',
team: input.team || '', team: input.team || '',
sport: (input.sport || 'nba').toString().toLowerCase(), sport: (input.sport || 'nba').toString().toLowerCase(),
// Wave 2A — real headshot ids (optional; card self-hides to a monogram).
playerId: input.playerId != null ? input.playerId : null,
espnId: input.espnId != null ? input.espnId : null,
stat: statLabel(input.stat), stat: statLabel(input.stat),
line, line,
side, side,
+94
View File
@@ -0,0 +1,94 @@
/* ============================================================
VYNDR — LIVE GRADE-SHIFT timeline helper (Wave 4B).
Pure CommonJS so the client component imports it (allowJs)
AND the Jest suite requires it directly.
Builds a grade/line-movement timeline from ALREADY-EMITTED data
(intradayRefreshService line history {t,line} + revised_from_grade).
It NEVER fabricates: absent/short history => { show:false }.
COLOR LAW (mirrors ROW-GRAMMAR + StatStrip.LineSparkline):
line movement is green = net move TOWARD the graded side,
amber = AGAINST, dim = flat. Never red — nothing here is settled.
For an OVER the "toward" sign is the raw line delta; for an UNDER
it is inverted (a line dropping steams the under).
============================================================ */
const TOWARD_COLOR = 'var(--g-a)'; // green
const AGAINST_COLOR = 'var(--amber)'; // amber
const FLAT_COLOR = 'var(--text-1)'; // dim — never red
const MIN_POINTS = 3; // self-hide below this (same floor as LineSparkline)
function isUnder(side) {
return String(side || 'O').toUpperCase().startsWith('U');
}
/** Classify a signed line delta relative to the graded side. */
function classifyMove(delta, side) {
const d = typeof delta === 'number' && Number.isFinite(delta) ? delta : 0;
const toward = isUnder(side) ? -d : d;
if (toward > 0) return { dir: 'toward', color: TOWARD_COLOR };
if (toward < 0) return { dir: 'against', color: AGAINST_COLOR };
return { dir: 'flat', color: FLAT_COLOR };
}
/** Keep only real {t, line} points (strict number guard — Number(null)===0). */
function cleanHistory(history) {
if (!Array.isArray(history)) return [];
return history
.filter((pt) => pt && typeof pt.line === 'number' && Number.isFinite(pt.line))
.map((pt) => ({ t: pt.t != null ? String(pt.t) : '', line: pt.line }));
}
/**
* Build the grade-shift timeline.
* prop: { history:[{t,line}], side, grade, revisedFrom|revised_from_grade, gradedLine }
* -> { show, points:[{t,line,delta,dir,color}], net:{delta,dir,color},
* revision:{from,to}|null, firstLine, lastLine }
*/
function buildGradeTimeline(prop = {}) {
const history = cleanHistory(prop.history);
const show = history.length >= MIN_POINTS;
const side = prop.side;
const revisedFrom = prop.revisedFrom || prop.revised_from_grade || null;
const grade = prop.grade || null;
const points = history.map((pt, i) => {
if (i === 0) return { t: pt.t, line: pt.line, delta: 0, dir: 'flat', color: FLAT_COLOR };
const delta = Math.round((pt.line - history[i - 1].line) * 100) / 100;
const cls = classifyMove(delta, side);
return { t: pt.t, line: pt.line, delta, dir: cls.dir, color: cls.color };
});
let net = { delta: 0, dir: 'flat', color: FLAT_COLOR };
if (history.length >= 2) {
const raw = Math.round((history[history.length - 1].line - history[0].line) * 100) / 100;
const cls = classifyMove(raw, side);
net = { delta: raw, dir: cls.dir, color: cls.color };
}
// A revision is real only when a prior grade was preserved AND it differs.
const revision = revisedFrom && grade && String(revisedFrom) !== String(grade)
? { from: String(revisedFrom), to: String(grade) }
: null;
return {
show,
points,
net,
revision,
firstLine: history.length ? history[0].line : null,
lastLine: history.length ? history[history.length - 1].line : null,
};
}
module.exports = {
buildGradeTimeline,
classifyMove,
cleanHistory,
isUnder,
TOWARD_COLOR,
AGAINST_COLOR,
FLAT_COLOR,
MIN_POINTS,
};
+114
View File
@@ -0,0 +1,114 @@
/* ============================================================
VYNDR — MARKET BREADTH / CONSENSUS-vs-MODEL (Wave 4A, Step 4).
Makes the DeskShowcase "consensus vs model, live line moves" claim REAL.
Given a prop's per-book rows + the model's projected value, compute the
market CONSENSUS (median book line) and the model's position vs that
consensus, SIGNED by the graded side (so an over that the model projects
ABOVE the market and an under it projects BELOW both read as a positive
edge → signal-green via the color contract).
DATA-SEMANTICS RULE: VYNDR never invents a market number. A consensus is
only honest with ≥2 DISTINCT books posting a finite line → otherwise null
(absent beats invented). A non-numeric line is IGNORED, never coerced to
0 (the classic `Number(null) === 0` fabrication bug).
Plain CommonJS so the .tsx strip imports it (allowJs) AND Jest exercises
the logic directly — same pattern as slateAdapter.js / colorContract.js.
============================================================ */
/** Strict numeric parse — null (never 0) when a value isn't a real number. */
function numOrNull(v) {
const n = typeof v === 'number' ? v : parseFloat(v);
return Number.isFinite(n) ? n : null;
}
const round2 = (x) => Math.round(x * 100) / 100;
/** Median of the finite numbers in `nums`. Empty / all-non-finite → null. */
function median(nums) {
const sorted = (Array.isArray(nums) ? nums : [])
.map(numOrNull)
.filter((n) => n != null)
.sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : round2((sorted[mid - 1] + sorted[mid]) / 2);
}
/**
* computeBreadth(books, modelValue, side) → breadth | null.
*
* `books` = the prop's per-book rows ([{ book, line, over_odds, under_odds }])
* — the same grouped shape the slate threads onto each prop. One opinion per
* DISTINCT book (dedupe by book name). <2 distinct books with a finite line
* → null (no honest consensus).
*
* Returns:
* consensus — median book line (the market's line)
* bookCount — distinct books contributing a finite line
* model — the model's projected value (numOrNull) or null
* delta — model consensus (signed to the MARKET), or null
* signedEdge — signed to the GRADED SIDE (positive = model beats market),
* or null when the model value is absent
* position — 'above' | 'below' | 'inline' (model vs consensus), or null
* side — normalized 'over' | 'under'
*/
function computeBreadth(books, modelValue, side = 'over') {
const rows = Array.isArray(books) ? books : [];
const byBook = new Map();
for (const r of rows) {
if (!r || !r.book) continue;
const ln = numOrNull(r.line);
if (ln == null) continue;
// First finite line per distinct book wins (one opinion per book).
if (!byBook.has(r.book)) byBook.set(r.book, ln);
}
if (byBook.size < 2) return null; // <2 books → never fabricate a consensus
const consensus = median([...byBook.values()]);
const isUnder = String(side || 'over').toLowerCase().startsWith('u');
const model = numOrNull(modelValue);
let delta = null;
let signedEdge = null;
let position = null;
if (model != null && consensus != null) {
delta = round2(model - consensus);
signedEdge = round2(isUnder ? consensus - model : model - consensus);
position = delta > 0 ? 'above' : delta < 0 ? 'below' : 'inline';
}
return {
consensus,
bookCount: byBook.size,
model,
delta,
signedEdge,
position,
side: isUnder ? 'under' : 'over',
};
}
/**
* collectBreadth(items, limit) — compute breadth for a list of props and
* return the qualifying rows ranked by |signedEdge| desc (the biggest model
* disagreements with the market lead). Non-qualifying props (<2 books) are
* dropped, so an empty result means the strip self-hides.
*
* `items` = [{ player, stat, side, line, books, modelValue }].
*/
function collectBreadth(items, limit = 6) {
const out = [];
for (const it of Array.isArray(items) ? items : []) {
if (!it) continue;
const b = computeBreadth(it.books, it.modelValue, it.side);
if (!b) continue;
out.push({ player: it.player, stat: it.stat, line: numOrNull(it.line), ...b });
}
const abs = (x) => Math.abs(numOrNull(x) == null ? 0 : numOrNull(x));
out.sort((a, b) => abs(b.signedEdge) - abs(a.signedEdge));
return out.slice(0, Math.max(0, limit));
}
module.exports = { median, computeBreadth, collectBreadth, numOrNull };
+78
View File
@@ -0,0 +1,78 @@
/* ============================================================
VYNDR — OUTLOOK MODE (Wave 4A, Step 3): the never-empty slate grid.
The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no
live games (and it's not a network failure) the grid falls back to REAL,
always-available data — the terminal is never dark, but it is never
fabricated either:
• yesterday's PROVEN A-tier receipts (settled ledger hits), and/or
• tomorrow's date-pinned ESPN schedule preview (free / cached).
A network `fetchError` is DISTINCT and stays an ERROR state — a fetch
failure is never dressed up as a fake outlook.
Plain CommonJS so the .tsx surfaces import it (allowJs) AND Jest exercises
the selection logic directly.
============================================================ */
const { buildHeroReceipts } = require('./slateAdapter');
/** Statuses that mean a game is no longer a PREVIEW (already underway/done). */
const NON_PREVIEW = new Set(['in', 'post', 'final', 'live', 'completed']);
/**
* mapTomorrowPreview(scheduleGames, limit) — map real schedule games (either
* the ESPN `{ awayTeam, homeTeam, gameTime, status }` shape OR the flattened
* `{ away, home, start_time }` shape) → compact preview rows. Upcoming only;
* a game missing either team is DROPPED (never fabricate a matchup).
*/
function mapTomorrowPreview(scheduleGames, limit = 8) {
const list = Array.isArray(scheduleGames) ? scheduleGames : [];
const out = [];
for (const g of list) {
if (!g) continue;
const status = String(g.status || g.state || '').toLowerCase();
if (NON_PREVIEW.has(status)) continue;
const away = g.away || g.awayTeam?.name || g.awayTeam?.abbreviation || null;
const home = g.home || g.homeTeam?.name || g.homeTeam?.abbreviation || null;
if (!away || !home) continue; // absent beats a fabricated fixture
out.push({
id: g.id || `${away}-${home}`,
away,
home,
time: g.gameTime || g.start_time || g.time || null,
sport: g.sport ? String(g.sport).toUpperCase() : null,
});
if (out.length >= Math.max(0, limit)) break;
}
return out;
}
/**
* buildOutlook(opts) →
* { mode: 'error' } — a network failure
* { mode: 'live' } — real games on the board
* { mode: 'outlook', receipts, tomorrow, hasContent }
*
* The outlook surface is ALWAYS non-error / non-blank at the surface level:
* even with no receipts and no schedule, the caller still renders the
* month-aware empty-state header — the grid never dead-ends in a CTA.
*
* @param {{ gamesCount?: number, fetchError?: unknown, settledRows?: unknown[], tomorrow?: unknown[] }} [opts]
*/
function buildOutlook(opts = {}) {
const { gamesCount = 0, fetchError = null, settledRows = [], tomorrow = [] } = opts;
if (fetchError) return { mode: 'error' };
if (Number(gamesCount) > 0) return { mode: 'live' };
const receipts = buildHeroReceipts(settledRows, 6);
const preview = mapTomorrowPreview(tomorrow, 8);
return {
mode: 'outlook',
receipts,
tomorrow: preview,
hasContent: receipts.length > 0 || preview.length > 0,
};
}
module.exports = { buildOutlook, mapTomorrowPreview };
+26 -56
View File
@@ -1,82 +1,52 @@
/** /**
* Player headshot URL construction (Session 19). * Player headshot URL construction (Session 19; Wave 2A refactor).
* *
* Each league hosts its own CDN; we don't proxy through ESPN as the * The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
* primary because (a) ESPN rate-limits image hotlinking and (b) the * requireable by the plain-JS Jest suite AND importable here (allowJs) — same
* league CDNs are the same sources PrizePicks and Sleeper use, so * single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
* coverage is closer to 100%. * file adds the TypeScript surface (types + the `headshotFromPlayer` helper).
*
* Each league hosts its own CDN; we don't proxy through ESPN as the primary
* because (a) ESPN rate-limits image hotlinking and (b) the league CDNs are the
* same sources PrizePicks and Sleeper use, so coverage is closer to 100%.
* *
* Fallback chain inside the resolver: * Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer, * 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
* where API-Football returns the photo in player responses), use * soccer URLs (no central CDN for player headshots).
* that directly. We DO NOT construct soccer URLs because no
* central CDN exists for player headshots.
* 2. League CDN with `playerId` — official source. * 2. League CDN with `playerId` — official source.
* 3. ESPN CDN fallback — only when `espnId` is present and no * 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
* league ID is available. Helpful while the roster table is * 4. `/images/player-silhouette.svg` — the sentinel the consuming component
* being backfilled with league-specific IDs. * swaps for a team-colored MONOGRAM (never a gray blob, never a broken
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette. * image). `<img onError>` also degrades a 404 to the monogram.
*
* `<img onError>` in the consuming component handles 404s from the
* CDN (e.g. a player who's in our roster but the league hasn't
* uploaded a headshot yet) by swapping to the silhouette.
*/ */
import {
getHeadshotUrl as coreGetHeadshotUrl,
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
} from '@/lib/playerHeadshotUrl';
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string; export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
export interface HeadshotInput { export interface HeadshotInput {
sport: HeadshotSport; sport: HeadshotSport;
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */ /** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
playerId?: string | number | null; playerId?: string | number | null;
/** Optional ESPN ID as a fallback when no league ID is known. */ /** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null; espnId?: string | number | null;
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */ /** Pre-cached photo URL (used by soccer where each league has no central CDN). */
cachedPhotoUrl?: string | null; cachedPhotoUrl?: string | null;
} }
export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg'; export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
const ESPN_SPORT_PATH: Record<string, string> = {
nba: 'nba',
wnba: 'wnba',
mlb: 'mlb',
// ESPN soccer headshots are inconsistent across leagues — explicitly
// omit so soccer falls through to silhouette unless a cached photo
// is provided.
};
export function getHeadshotUrl(input: HeadshotInput): string { export function getHeadshotUrl(input: HeadshotInput): string {
const sport = String(input.sport || '').toLowerCase(); return coreGetHeadshotUrl(input);
const playerId = input.playerId != null ? String(input.playerId) : '';
const espnId = input.espnId != null ? String(input.espnId) : '';
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
if (cached) return cached;
if (playerId) {
switch (sport) {
case 'nba':
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
case 'wnba':
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
case 'mlb':
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
default:
break;
}
}
if (espnId && ESPN_SPORT_PATH[sport]) {
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
}
return PLAYER_SILHOUETTE;
} }
/** /**
* Convenience wrapper for the common case where the caller has a * Convenience wrapper for the common case where the caller has a player object
* player object with mixed ID fields. Pulls the first non-empty ID * with mixed ID fields. Pulls the first non-empty ID out of the union before
* out of the union before delegating to `getHeadshotUrl`. * delegating to `getHeadshotUrl`.
*/ */
export function headshotFromPlayer(player: { export function headshotFromPlayer(player: {
sport?: string; sport?: string;
+61
View File
@@ -0,0 +1,61 @@
/**
* Player headshot URL construction — PURE core (Wave 2A).
*
* CommonJS on purpose: importable by the `.ts` resolver (`playerHeadshot.ts`,
* allowJs) AND requireable by the plain-JS Jest suite — same doctrine as
* `vyndrTokens.js` / `playerName.js`. Keep the URL logic HERE so it stays
* genuinely unit-testable (jest can't transform the `.ts`).
*
* Each league hosts its own CDN. Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — a stored URL (soccer, where API-Football returns the
* photo). We DO NOT construct soccer URLs (no central CDN).
* 2. League CDN with `playerId` — official source.
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
* 4. `/images/player-silhouette.svg` — the sentinel the component swaps for a
* team-colored MONOGRAM (never a gray blob, never a broken image).
*/
const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
const ESPN_SPORT_PATH = {
nba: 'nba',
wnba: 'wnba',
mlb: 'mlb',
// Wave 2A — dormant leagues (not in ACTIVE_SPORTS + off-season). Cheap
// correctness: an ESPN athlete id ingested later resolves for free.
nfl: 'nfl',
nhl: 'nhl',
// ESPN soccer headshots are inconsistent across leagues — explicitly omit so
// soccer falls through to the silhouette unless a cached photo is provided.
};
function getHeadshotUrl(input) {
input = input || {};
const sport = String(input.sport || '').toLowerCase();
const playerId = input.playerId != null ? String(input.playerId) : '';
const espnId = input.espnId != null ? String(input.espnId) : '';
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
if (cached) return cached;
if (playerId) {
switch (sport) {
case 'nba':
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
case 'wnba':
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
case 'mlb':
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
default:
break;
}
}
if (espnId && ESPN_SPORT_PATH[sport]) {
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
}
return PLAYER_SILHOUETTE;
}
module.exports = { PLAYER_SILHOUETTE, ESPN_SPORT_PATH, getHeadshotUrl };
+4
View File
@@ -35,6 +35,10 @@ const OPEN_ROUTES = [
'/dashboard', '/dashboard',
'/slate', '/slate',
'/scan', '/scan',
/* Wave 4B — the Parlay Lab is the parlay-building funnel: anon/free reach it
(free 2-leg cap + payout-blur upsell), same monetization logic as scan +
dashboard, so it stays OPEN, not gated. */
'/parlay',
'/compare', '/compare',
'/game', '/game',
'/pricing', '/pricing',
+8
View File
@@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
player: displayName(p.player), player: displayName(p.player),
team: knownTeam, team: knownTeam,
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
// Wave 2A — real headshot id threaded from the snapshot grade
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
lineup: lineupStatusFor(pk, knownTeam), lineup: lineupStatusFor(pk, knownTeam),
injury: injuryFor(pk), injury: injuryFor(pk),
stats: [], stats: [],
@@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
const cand = displayName(p.player); const cand = displayName(p.player);
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand; if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype }; if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
// Fill an id from a later graded row if the first row lacked one.
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
} }
if (rec) { if (rec) {
const side = sideCh(rec.direction); const side = sideCh(rec.direction);
@@ -596,6 +603,7 @@ module.exports = {
isRelevantGame, isRelevantGame,
indexGrades, indexGrades,
indexDeltas, indexDeltas,
gradeKey,
statShort, statShort,
gradedAgo, gradedAgo,
buildPlayerStripsFromProps, buildPlayerStripsFromProps,
+10 -1
View File
@@ -195,7 +195,16 @@ function resolveTeam(key, sport) {
const up = String(key).toUpperCase().trim(); const up = String(key).toUpperCase().trim();
if (table[up]) return { abbr: up, sport: sp, ...table[up] }; if (table[up]) return { abbr: up, sport: sp, ...table[up] };
// statsapi/ESPN alias fallbacks for the two MLB mismatches // statsapi/ESPN alias fallbacks for the two MLB mismatches
const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' }; // Global alias table. resolveTeam checks the real abbr FIRST, so an entry
// only fires for a sport whose table lacks that key — e.g. WSH resolves
// directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to
// WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit
// (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here.
const ALIAS = {
ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD',
GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX',
SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON',
};
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] }; if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) { if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
const a = SOCCER_ALIAS[normKey(key)]; const a = SOCCER_ALIAS[normKey(key)];
+99
View File
@@ -0,0 +1,99 @@
/* ============================================================
VYNDR — RECORD BY GRADE TIER (Wave 3, Addition 2).
Plain CommonJS so the TierRecord.tsx component imports it AND
the Jest suite can require it directly (no TS/Babel transform).
The credibility of the model IS the tier calibration: A+ wins
more than A, A more than B, and so on. A single blended "67%"
hides that gradient. This module turns a `by_tier` map (from
ledgerService.getModelAggregate) into ordered, display-ready
rows so EVERY surface (dashboard, /u, ledger) renders the same
honest table.
HONESTY RULES (do not soften):
- The W-L record (counts) is shown ALWAYS, even at small n —
an honest 3-1 beats hiding it.
- The hit-% is shown ONLY when that tier's `hit_pct != null`
(the n≥20 gate is already applied UPSTREAM in
getModelAggregate — never re-derive it here). Below the gate:
"RECORD BUILDING · N settled".
- A+ stands alone; A/B/C/D/F bucket by first letter (mirrors
outcomeService + getModelAggregate's `tierOf`).
============================================================ */
/* Canonical render order. A+ is its own bucket; the rest are
first-letter buckets. Mirrors getModelAggregate.tierOf. */
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
/* A-tier (A+ / A) is the only bucket that earns the green edge
color — "GLOW RESERVED FOR A-TIER ONLY" (design reference §05).
Everything below A stays neutral/muted; red is reserved for
settled-negative outcomes, never a tier label. */
function isEdgeTier(tier) {
return tier === 'A+' || tier === 'A';
}
/* One tier's display row. `hitPct` is passed through verbatim —
null means "below the n≥20 gate" and the row renders BUILDING. */
function buildTierRow(tier, bucket, minSample) {
const settled = Number(bucket && bucket.settled) || 0;
const hits = Number(bucket && bucket.hits) || 0;
const misses = Number(bucket && bucket.misses) || 0;
const pushes = Number(bucket && bucket.pushes) || 0;
// hit_pct is the gated value from the server — strict null check
// (Number(null) === 0 would fabricate a 0% record).
const hitPct = bucket && bucket.hit_pct != null ? Number(bucket.hit_pct) : null;
const ready = hitPct != null;
return {
tier,
settled,
hits,
misses,
pushes,
record: `${hits}-${misses}`,
hitPct,
ready,
edge: isEdgeTier(tier),
// BUILDING copy the UI shows in place of a small-sample %.
buildingLabel: `RECORD BUILDING · ${settled} settled`,
minSample: Number(minSample) || 20,
};
}
/**
* Turn a `by_tier` map into ordered rows, one per tier that has
* at least one settled read. Order is always A+ → F. Tiers with
* zero settles are omitted (nothing to prove yet — never a fake
* empty row).
*
* @param {Object} byTier getModelAggregate().by_tier
* @param {number} [minSample=20]
* @returns {Array} ordered display rows
*/
function buildTierRows(byTier, minSample = 20) {
const map = byTier && typeof byTier === 'object' ? byTier : {};
const rows = [];
for (const tier of TIER_ORDER) {
const bucket = map[tier];
if (!bucket) continue;
const row = buildTierRow(tier, bucket, minSample);
if (row.settled <= 0) continue; // no settled reads → not shown
rows.push(row);
}
return rows;
}
/** True when at least one tier has a real (gated) hit-% to show —
* a surface can use this to decide whether to show the "PROVEN"
* framing vs the plain building table. */
function anyTierReady(byTier) {
return buildTierRows(byTier).some((r) => r.ready);
}
module.exports = {
TIER_ORDER,
isEdgeTier,
buildTierRow,
buildTierRows,
anyTierReady,
};
+3
View File
@@ -25,6 +25,9 @@ const SPORT = {
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' }, mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' }, wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' }, soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
// Wave 6 — combat: the #D4AF37 championship-gold token (matches
// src/services/shareCards/tokens.js + config/sports.js mma color).
mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' },
}; };
/* GradeBadge size variants — hero stays 80120px (§5: grade letter is /* GradeBadge size variants — hero stays 80120px (§5: grade letter is