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>
@@ -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 → `{ profile: { handle, published, created_at } | null }`.
|
||||
POST `{ handle, published }` → upsert own row (service role). Handle must
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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)
|
||||
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
|
||||
|
||||
@@ -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. 26–1, 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.
|
||||
@@ -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.
|
||||
@@ -152,6 +152,12 @@ app.use('/api/widget', widgetRoutes);
|
||||
// stat-filtered views over all of them.
|
||||
const scheduleRoutes = require('./routes/schedule');
|
||||
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,
|
||||
// cache-only, never triggers a snapshot.
|
||||
const tickerRoutes = require('./routes/ticker');
|
||||
|
||||
@@ -41,7 +41,11 @@ const SPORTS = Object.freeze({
|
||||
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' },
|
||||
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' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -19,6 +19,13 @@
|
||||
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
|
||||
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
|
||||
* 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');
|
||||
@@ -30,6 +37,10 @@ const router = express.Router();
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
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.
|
||||
const NOT_FOUND = { error: 'Profile not found' };
|
||||
// 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();
|
||||
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
|
||||
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();
|
||||
if (!sb) return res.status(404).json(NOT_FOUND);
|
||||
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;
|
||||
|
||||
@@ -2,7 +2,8 @@ const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getStatFilters } = require('../config/statFilters');
|
||||
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 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
|
||||
// graded props by confidence) for the Terminal / Stats Explorer.
|
||||
router.get('/leaders', intelLimit, async (req, res) => {
|
||||
|
||||
@@ -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 (26–1 with an en-dash, or 26–1–0 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 || '')}®ions=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,
|
||||
};
|
||||
@@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) {
|
||||
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
|
||||
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.
|
||||
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
|
||||
const classifierInput = parseAthleteStats(stats);
|
||||
@@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) {
|
||||
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
|
||||
position: (athlete.position && athlete.position.abbreviation) || '',
|
||||
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 */ }
|
||||
return result;
|
||||
|
||||
@@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) {
|
||||
return matchPlayers(people, query, opts.limit || 12);
|
||||
}
|
||||
|
||||
async function searchPlayer(name, season = DEFAULT_SEASON) {
|
||||
// ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
|
||||
// Two different players can share an EXACT nameKey ("James Wood" — the Nationals
|
||||
// star + a Cubs-affiliate namesake). Taking the first `.find` match silently
|
||||
// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in
|
||||
// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a
|
||||
// confident team hint (the prop's game participants); otherwise refuse (null).
|
||||
|
||||
// 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 first = parts[0] || '';
|
||||
const last = parts[parts.length - 1] || '';
|
||||
if (!(first && last && first !== last)) return null;
|
||||
const cands = (people || []).filter((p) => {
|
||||
const k = nameKey(p.fullName).split(' ');
|
||||
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
|
||||
});
|
||||
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;
|
||||
const url = `${BASE}/sports/1/players?season=${season}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
|
||||
const people = (data && Array.isArray(data.people)) ? data.people : [];
|
||||
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
|
||||
if (!hit) {
|
||||
const parts = targetKey.split(' ');
|
||||
const first = parts[0] || '';
|
||||
const last = parts[parts.length - 1] || '';
|
||||
if (first && last && first !== last) {
|
||||
const cands = people.filter((p) => {
|
||||
const k = nameKey(p.fullName).split(' ');
|
||||
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
|
||||
|
||||
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;
|
||||
return {
|
||||
id: hit.id,
|
||||
fullName: hit.fullName ?? name,
|
||||
team: hit.currentTeam?.name ?? null,
|
||||
teamId: hit.currentTeam?.id ?? null,
|
||||
team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
|
||||
teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : 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
|
||||
* { found: false } on any miss/failure (never throws).
|
||||
*/
|
||||
async function getPlayerStats(name, season = DEFAULT_SEASON) {
|
||||
async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
|
||||
try {
|
||||
const person = await searchPlayer(name, season);
|
||||
const person = await searchPlayer(name, season, opts);
|
||||
if (!person) return { found: false };
|
||||
const group = person.position === 'P' ? 'pitching' : 'hitting';
|
||||
const [seasonStat, log] = await Promise.all([
|
||||
@@ -293,5 +394,9 @@ module.exports = {
|
||||
getTeams,
|
||||
resolveTeam,
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
// 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. */
|
||||
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). */
|
||||
function getArchetype(name) {
|
||||
@@ -370,6 +472,13 @@ function getArchetype(name) {
|
||||
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:
|
||||
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
||||
@@ -384,7 +493,12 @@ function classify(sport, stats = {}) {
|
||||
.filter(([, v]) => v > 0)
|
||||
.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 (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
|
||||
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
|
||||
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 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
|
||||
? getArchetype(ranked[1][0])
|
||||
? resolver(ranked[1][0])
|
||||
: null;
|
||||
|
||||
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 classifyWNBA = (stats) => classify('wnba', stats);
|
||||
const classifyMLB = (stats) => classify('mlb', stats);
|
||||
|
||||
const classifyMMA = (stats) => classify('mma', stats);
|
||||
|
||||
module.exports = {
|
||||
ARCHETYPES,
|
||||
COMBAT_ARCHETYPES,
|
||||
DISCIPLINE_PEDIGREES,
|
||||
getArchetype,
|
||||
getCombatArchetype,
|
||||
classify,
|
||||
classifyNBA,
|
||||
classifyWNBA,
|
||||
classifyMLB,
|
||||
classifyMMA,
|
||||
styleMatchup,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,11 @@ const SPORT_KEYS = {
|
||||
// keys added in Session 31; NHL keys were added alongside this wiring.
|
||||
nfl: 'americanfootball_nfl',
|
||||
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
|
||||
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
||||
soccer_wc: 'soccer_fifa_world_cup',
|
||||
@@ -142,6 +147,10 @@ const SOCCER_MARKETS = [
|
||||
'player_passes',
|
||||
'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) {
|
||||
return [...markets, 'spreads'].join(',');
|
||||
@@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({
|
||||
mlb: buildMarketString(MLB_MARKETS),
|
||||
nfl: buildMarketString(NFL_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
|
||||
// Every soccer league code shares the same market set.
|
||||
...Object.fromEntries(
|
||||
@@ -499,6 +510,8 @@ module.exports = {
|
||||
getCacheKey,
|
||||
SPORT_KEYS,
|
||||
SOCCER_SPORT_KEYS,
|
||||
// Wave 6 — combat game-level markets (moneyline + round total).
|
||||
MMA_MARKETS,
|
||||
// Session 16 — per-sport market scoping.
|
||||
SPORT_MARKETS,
|
||||
getMarketsForSport,
|
||||
|
||||
@@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
try {
|
||||
if (sp === 'mlb') {
|
||||
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 };
|
||||
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
|
||||
// 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 extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -298,6 +298,25 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
|
||||
// and the slate join guard (a prop only attaches to its own game).
|
||||
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 +
|
||||
// hot lists) starved because its data producers were all external and
|
||||
// unarmed (tank01-prefetch via n8n, the offline Python grading flow).
|
||||
@@ -308,11 +327,15 @@ async function runSnapshot(sport, opts = {}) {
|
||||
const logEntries = [];
|
||||
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
|
||||
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) {
|
||||
const c = deps.classify(sp, stats.classifierInput || {});
|
||||
archByPlayer[player] = c.primary ? c.primary.name : null;
|
||||
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) {
|
||||
logEntries.push({
|
||||
name: normalizeName(player).display || player,
|
||||
@@ -328,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
|
||||
});
|
||||
await mergeRosterLogs(sp, logEntries, deps);
|
||||
|
||||
const enriched = graded.map((g) => ({
|
||||
...g,
|
||||
gradedAt: gradedAtFor(g, oddsByKey, ts),
|
||||
archetype: archByPlayer[g.player || g.player_name] || null,
|
||||
team: teamByPlayer[g.player || g.player_name] || g.team || null,
|
||||
}));
|
||||
const enriched = graded.map((g) => {
|
||||
const pn = g.player || g.player_name;
|
||||
return {
|
||||
...g,
|
||||
gradedAt: gradedAtFor(g, oddsByKey, ts),
|
||||
archetype: archByPlayer[pn] || 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.
|
||||
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
|
||||
|
||||
@@ -83,6 +83,16 @@ const MARKET_MAP = {
|
||||
// are shared with soccer/NBA — sport context discriminates downstream.
|
||||
player_shots_on_goal: 'shots_on_goal',
|
||||
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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 || '—'}");
|
||||
});
|
||||
});
|
||||
@@ -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('22–2'); // 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({});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () =>
|
||||
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('<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('$34.99'); // the founder price
|
||||
// real feature visuals fill the right half (kills the dead half, #8)
|
||||
|
||||
@@ -18,6 +18,25 @@ describe('resolveTeam — abbr / full name / nickname / alias', () => {
|
||||
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
||||
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)', () => {
|
||||
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
||||
expect(resolveTeam('', 'mlb')).toBeNull();
|
||||
|
||||
@@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => {
|
||||
expect(r.found).toBe(true);
|
||||
expect(r.team).toBe('DAL');
|
||||
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 () => {
|
||||
@@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => {
|
||||
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
|
||||
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
|
||||
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.team).toBe('DAL');
|
||||
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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -244,4 +244,14 @@ describe('oddsNormalizer', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => {
|
||||
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 () => {
|
||||
const cache = memCache();
|
||||
let line = 1.5;
|
||||
|
||||
@@ -64,14 +64,23 @@ describe('SPORT_MARKETS — isolation', () => {
|
||||
expect(wc).not.toMatch(/batter_/);
|
||||
});
|
||||
|
||||
test('every market list ends with `spreads`', () => {
|
||||
for (const list of Object.values(SPORT_MARKETS)) {
|
||||
test('every PLAYER-PROP market list ends with `spreads`', () => {
|
||||
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,
|
||||
// only that it's present in the comma-separated list.
|
||||
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', () => {
|
||||
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
|
||||
// below as intelligence layers on top of the raw odds.
|
||||
import Slate from '@/components/Slate';
|
||||
// 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.
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
// 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
|
||||
// paint ALWAYS proves the model (#1, #13, Part 6).
|
||||
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 { currentAccessToken } from '@/lib/authToken';
|
||||
|
||||
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
|
||||
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' }}>
|
||||
✓ 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() {
|
||||
const router = useRouter();
|
||||
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
|
||||
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
||||
row isn't identical-weight noise; when tonight is empty we fall back to
|
||||
@@ -422,21 +526,9 @@ export default function DashboardPage() {
|
||||
{games === null ? (
|
||||
<SkeletonRow stacked />
|
||||
) : games.length === 0 ? (
|
||||
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
|
||||
// an off-season sport says when it returns; in-season = off-day.
|
||||
<div
|
||||
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>
|
||||
// Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
|
||||
// Yesterday's proven receipts + tomorrow's real schedule fill the grid.
|
||||
<DashboardOutlook sport={sport} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{games.map((g, idx) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 || '')} />;
|
||||
}
|
||||
@@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
||||
/* Terminal's multi-column grid stacks on mobile. */
|
||||
@media (max-width: 768px) {
|
||||
.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 ──────── */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* 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
|
||||
@@ -305,7 +282,20 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +355,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<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. */}
|
||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
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
|
||||
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
|
||||
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 />
|
||||
{/* 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>
|
||||
{/* Founder-seat scarcity meter (§12) */}
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
|
||||
@@ -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'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'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,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||
import PitcherArsenal from '@/components/vyndr/PitcherArsenal';
|
||||
import { archetypeInfo } from '@/lib/archetypes';
|
||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||
|
||||
@@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
|
||||
</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 */}
|
||||
{p.activeProps?.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DeskShowcase() {
|
||||
THE DESK · FLAGSHIP
|
||||
</div>
|
||||
<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 style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
||||
</h2>
|
||||
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
|
||||
</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 }}>
|
||||
<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' }}>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const metadata: Metadata = {
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<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. */}
|
||||
<DeskShowcase />
|
||||
<Pricing />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { currentAccessToken } from '@/lib/authToken';
|
||||
import { classifyRenewal } from '@/lib/billingDisplay';
|
||||
|
||||
interface FullProfile {
|
||||
id: string;
|
||||
@@ -125,7 +126,7 @@ export default function ProfilePage() {
|
||||
{tier !== 'free' && (
|
||||
<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="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
||||
<Stat label="Renews" value={renewalLabel(profile.subscription_end)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -170,7 +171,7 @@ export default function ProfilePage() {
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
||||
Cancellation scheduled. Access ends{' '}
|
||||
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
||||
{accessEndsLabel(profile.subscription_end)}.
|
||||
</p>
|
||||
</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 {
|
||||
if (tier === 'desk') return 'var(--grade-a)';
|
||||
if (tier === 'analyst') return 'var(--grade-b)';
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
trackScanLimitHit,
|
||||
trackUpgradeClicked,
|
||||
} 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';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
@@ -119,6 +120,9 @@ export default function ScanPage() {
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
||||
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 [line, setLine] = useState<string>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
@@ -306,6 +310,7 @@ export default function ScanPage() {
|
||||
setError('');
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setSelectedPlayerId(null);
|
||||
setLine('');
|
||||
};
|
||||
|
||||
@@ -442,7 +447,6 @@ export default function ScanPage() {
|
||||
}}
|
||||
>
|
||||
{tonightsPlayers.map((p) => {
|
||||
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
|
||||
const selected = selectedPlayer === p.name;
|
||||
return (
|
||||
<button
|
||||
@@ -450,6 +454,7 @@ export default function ScanPage() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(p.name);
|
||||
setSelectedPlayerId(null); // tonight chips carry no id → monogram
|
||||
setPlayerQuery(p.name);
|
||||
setPlayerSuggestions([]);
|
||||
// If the player has exactly one stat type with
|
||||
@@ -475,22 +480,9 @@ export default function ScanPage() {
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={headshot}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
{/* Wave 2A — tonight's players carry no id → team-colored
|
||||
monogram (branded, never a gray silhouette). */}
|
||||
<PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
|
||||
{p.name}
|
||||
</span>
|
||||
@@ -511,6 +503,7 @@ export default function ScanPage() {
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
setSelectedPlayerId(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -551,33 +544,25 @@ export default function ScanPage() {
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedPlayer(p.full_name);
|
||||
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
|
||||
setPlayerQuery(p.full_name);
|
||||
setPlayerSuggestions([]);
|
||||
}}
|
||||
style={suggestionStyle}
|
||||
>
|
||||
{/* Session 19 — headshot in search suggestions. The
|
||||
/api/players/search response doesn't (yet) include
|
||||
league IDs, so we hit ESPN-CDN fallback or
|
||||
silhouette. onError swaps in the silhouette when
|
||||
the league hasn't published one. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })}
|
||||
alt=""
|
||||
width={28}
|
||||
height={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',
|
||||
}}
|
||||
/>
|
||||
{/* Wave 2A — real headshot in search suggestions. MLB's
|
||||
/api/players/search carries the MLBAM id on p.id (numeric);
|
||||
NBA/WNBA ids are synthetic → guarded out → team-colored
|
||||
monogram. Never a gray silhouette. */}
|
||||
<span style={{ marginRight: 10, display: 'inline-flex' }}>
|
||||
<PlayerAvatar
|
||||
name={p.full_name}
|
||||
sport={sport.toLowerCase() as HeadshotSport}
|
||||
playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
|
||||
team={p.team}
|
||||
size={28}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
|
||||
{p.team && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
@@ -731,6 +716,7 @@ export default function ScanPage() {
|
||||
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
||||
data={mapScanToGradeResult({
|
||||
player: selectedPlayer,
|
||||
playerId: selectedPlayerId,
|
||||
sport,
|
||||
stat,
|
||||
line: Number(line),
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
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.
|
||||
@@ -36,6 +39,8 @@ interface ProfileRow {
|
||||
revised_from_grade?: string | null;
|
||||
}
|
||||
|
||||
interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null }
|
||||
|
||||
interface ProfileAggregate {
|
||||
settled: number;
|
||||
hits: number;
|
||||
@@ -46,6 +51,9 @@ interface ProfileAggregate {
|
||||
clv_beat: number;
|
||||
beat_close_pct: number | null;
|
||||
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 {
|
||||
@@ -53,6 +61,11 @@ interface ProfilePayload {
|
||||
aggregate: ProfileAggregate | null;
|
||||
entries: ProfileRow[];
|
||||
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> = {
|
||||
@@ -107,24 +120,35 @@ export default function PublicProfile({ handle }: { handle: string }) {
|
||||
|
||||
const agg = data.aggregate;
|
||||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||||
const isHouse = Boolean(data.house);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<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>
|
||||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
@{data.handle}
|
||||
{isHouse ? 'VYNDR MODEL' : `@${data.handle}`}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
Every settled read. Wins and misses. Nothing curated.
|
||||
{isHouse
|
||||
? 'The VYNDR model’s public record. Every graded read, wins and misses, closing-line value included.'
|
||||
: 'Every settled read. Wins and misses. Nothing curated.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Record header — never a percentage under min_sample settles. */}
|
||||
<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 ? (
|
||||
<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 }}>
|
||||
@@ -272,7 +296,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
||||
</div>
|
||||
</div>
|
||||
<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>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const contentType = 'image/png';
|
||||
|
||||
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 {
|
||||
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||
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 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 };
|
||||
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;
|
||||
}
|
||||
@@ -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', flexDirection: 'column' }}>
|
||||
<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 style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
|
||||
@{h}
|
||||
{rec?.house ? 'VYNDR MODEL' : `@${h}`}
|
||||
</div>
|
||||
{rec ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
|
||||
{visible.map((p) => (
|
||||
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
|
||||
<span style={rankStyle}>#{p.rank}</span>
|
||||
<img
|
||||
src={getHeadshotUrl({ sport, playerId: p.playerId })}
|
||||
alt={p.name}
|
||||
width={36}
|
||||
height={36}
|
||||
style={avatarStyle}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||
{/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
|
||||
team-colored monogram otherwise. Never a gray silhouette. */}
|
||||
<PlayerAvatar
|
||||
name={p.name}
|
||||
sport={sport as HeadshotSport}
|
||||
playerId={p.playerId}
|
||||
team={p.team}
|
||||
size={36}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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,
|
||||
color: 'var(--text-tertiary, #6A6A78)',
|
||||
};
|
||||
const avatarStyle: React.CSSProperties = {
|
||||
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
|
||||
};
|
||||
const playerName: React.CSSProperties = {
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [
|
||||
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
|
||||
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
||||
id: 'desk',
|
||||
|
||||
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
|
||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
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.
|
||||
// Grades never change in-game; these marks are tracking, labeled as such.
|
||||
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
||||
@@ -165,7 +172,7 @@ interface StreakApiRow {
|
||||
interface StreaksResponse { streaks?: StreakApiRow[] }
|
||||
|
||||
// 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 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 {
|
||||
initialTab?: SlateTab;
|
||||
tier?: Tier;
|
||||
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
)) as SlateGame[];
|
||||
}, [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
|
||||
// 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
|
||||
@@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && filteredGames.length === 0 && (
|
||||
{/* A search miss keeps its own scan-it CTA. */}
|
||||
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
@@ -976,46 +1122,65 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
|
||||
// off-season sports name their return window; in-season = off-day.
|
||||
(() => {
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 }}>
|
||||
{orderedGames.map((g, i) => (
|
||||
<VyndrGameCard
|
||||
|
||||
@@ -18,11 +18,12 @@ interface ArchetypeBadgeProps {
|
||||
*/
|
||||
export default function ArchetypeBadge({
|
||||
archetype,
|
||||
sport,
|
||||
variant = 'tint',
|
||||
size = 'sm',
|
||||
showDesc = false,
|
||||
}: ArchetypeBadgeProps) {
|
||||
const s = badgeStyle(archetype, variant, size);
|
||||
const s = badgeStyle(archetype, variant, size, sport);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||
<span
|
||||
@@ -46,10 +47,20 @@ export default function ArchetypeBadge({
|
||||
textShadow: s.textShadow,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
||||
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
||||
/>
|
||||
{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
|
||||
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
||||
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
||||
/>
|
||||
)}
|
||||
{s.name}
|
||||
</span>
|
||||
{showDesc && s.desc && (
|
||||
|
||||
@@ -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
|
||||
* the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
|
||||
* lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts
|
||||
* where the BookChip tile is too heavy. `best` gives it the signal ring.
|
||||
* BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a
|
||||
* bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8
|
||||
* major books) it renders that; otherwise it falls back to the real book NAME
|
||||
* 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({
|
||||
book,
|
||||
@@ -16,6 +22,8 @@ export default function BookWordmark({
|
||||
size?: number;
|
||||
}) {
|
||||
const b = bookInfo(book);
|
||||
const slug = bookSlug(book);
|
||||
const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null;
|
||||
return (
|
||||
<span
|
||||
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)' }} />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@
|
||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||
import { accentColor } from '@/lib/teamMeta';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { isPreferredBook } from '@/lib/books';
|
||||
import { isPreferredBook, bookInfo } from '@/lib/books';
|
||||
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
@@ -37,6 +37,9 @@ export interface GameProp {
|
||||
export interface PlayerStrip {
|
||||
player: string;
|
||||
team: string;
|
||||
// Wave 2A — real headshot ids threaded from the snapshot grade.
|
||||
playerId?: string | number | null;
|
||||
espnId?: string | number | null;
|
||||
archetype?: StripArchetype;
|
||||
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
|
||||
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>
|
||||
{g.lines.map((ln, i) => (
|
||||
<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.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
||||
<LineCell value={ln.ou} best={ln.bestOU} />
|
||||
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
player={ps.player}
|
||||
team={ps.team}
|
||||
sport={g.sport}
|
||||
playerId={ps.playerId}
|
||||
espnId={ps.espnId}
|
||||
archetype={ps.archetype}
|
||||
lineup={ps.lineup}
|
||||
injury={ps.injury}
|
||||
|
||||
@@ -6,6 +6,9 @@ import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import VBtn from '@/components/vyndr/VBtn';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
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 { edgeColor, gradeGlows } from '@/lib/colorContract';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
@@ -14,6 +17,10 @@ export interface GradeResultData {
|
||||
player: string;
|
||||
team: 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;
|
||||
line: number;
|
||||
side: 'Over' | 'Under';
|
||||
@@ -33,6 +40,12 @@ export interface GradeResultData {
|
||||
propDNA?: { reliable: string[]; volatile: string[] };
|
||||
statContext?: { season?: string; last10?: string; vsOpp?: 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 {
|
||||
@@ -96,12 +109,17 @@ export default function GradeResultCard({
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
<SportBadge sport={d.sport} />
|
||||
@@ -187,6 +205,12 @@ export default function GradeResultCard({
|
||||
))}
|
||||
</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 */}
|
||||
{d.signals.length > 0 && (
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,17 +21,26 @@ export default function PlayerAvatar({
|
||||
name,
|
||||
sport = 'mlb',
|
||||
playerId,
|
||||
espnId,
|
||||
team,
|
||||
size = 36,
|
||||
}: {
|
||||
name: string;
|
||||
sport?: HeadshotSport;
|
||||
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
|
||||
playerId?: string | number | null;
|
||||
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||
espnId?: string | number | null;
|
||||
team?: string | null;
|
||||
size?: number;
|
||||
}) {
|
||||
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 showImg = url && url !== '/images/player-silhouette.svg' && !broken;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
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.
|
||||
@@ -31,6 +33,8 @@ interface ResultItem {
|
||||
sub: string; // team / abbr context
|
||||
sport: 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 }) {
|
||||
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
const seen = new Set<string>();
|
||||
const merged: ResultItem[] = [];
|
||||
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();
|
||||
if (!name) continue;
|
||||
const key = `${name.toLowerCase()}|${res.sport}`;
|
||||
if (seen.has(key)) continue;
|
||||
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({
|
||||
kind: 'player',
|
||||
label: name,
|
||||
sub: p.team ? String(p.team) : '',
|
||||
sport: res.sport,
|
||||
href: playerHref(name, res.sport),
|
||||
playerId,
|
||||
});
|
||||
if (merged.length >= 9) break;
|
||||
}
|
||||
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
|
||||
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
|
||||
{/* 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>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{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)' }}>
|
||||
|
||||
@@ -195,6 +195,10 @@ interface StatStripProps {
|
||||
player: string;
|
||||
team: 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;
|
||||
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
|
||||
lineup?: { status: string; slot?: number } | null;
|
||||
@@ -225,6 +229,8 @@ export default function StatStrip({
|
||||
player,
|
||||
team,
|
||||
sport,
|
||||
playerId,
|
||||
espnId,
|
||||
archetype,
|
||||
lineup,
|
||||
injury,
|
||||
@@ -378,7 +384,7 @@ export default function StatStrip({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
{/* DS0 — player identity block: real headshot / team-colored monogram
|
||||
(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>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
||||
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export { default as Card } from './Card';
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
export { default as Ticker } from './Ticker';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as MarketBreadth } from './MarketBreadth';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { default as GradeResultCard } 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 AccuracyBadge } from './AccuracyBadge';
|
||||
export { default as ModelRecord } from './ModelRecord';
|
||||
export { default as TierRecord } from './TierRecord';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
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 type { BlendSegment } from './ArchetypeBlend';
|
||||
export { default as StatStrip } 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';
|
||||
|
||||
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
|
||||
|
||||
@@ -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' },
|
||||
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' },
|
||||
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' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
};
|
||||
|
||||
@@ -84,6 +84,21 @@ const ARCHETYPE_MAP = {
|
||||
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: '' };
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function archetypeInfo(name) {
|
||||
function archetypeInfo(name, sport) {
|
||||
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 (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
function archetypeColor(name) {
|
||||
return archetypeInfo(name).c;
|
||||
function archetypeColor(name, sport) {
|
||||
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) {
|
||||
@@ -114,8 +140,8 @@ function glyphSvg(glyphKey) {
|
||||
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||
* size: 'sm' | 'md'.
|
||||
*/
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
const info = archetypeInfo(name);
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm', sport) {
|
||||
const info = archetypeInfo(name, sport);
|
||||
const sm = size === 'sm';
|
||||
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||
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.
|
||||
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 {
|
||||
name: canonical,
|
||||
desc: info.d,
|
||||
glyph: info.g,
|
||||
// Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys).
|
||||
glyphChar: isCombat ? info.char : null,
|
||||
color: info.c,
|
||||
textColor, bg, borderColor, glyphColor, textShadow,
|
||||
fontSize: sm ? '9.5px' : '12px',
|
||||
@@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
module.exports = {
|
||||
GLYPHS,
|
||||
ARCHETYPE_MAP,
|
||||
COMBAT_ARCHETYPE_MAP,
|
||||
archetypeInfo,
|
||||
archetypeColor,
|
||||
combatArchetypeColor,
|
||||
glyphSvg,
|
||||
badgeStyle,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,29 +1,69 @@
|
||||
/* 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 = {
|
||||
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||
DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||
FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||
MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', 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 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' },
|
||||
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
|
||||
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||
FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||
FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||
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' },
|
||||
};
|
||||
|
||||
// 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) {
|
||||
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"
|
||||
@@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) {
|
||||
return preferred.some((p) => bookKey(p) === k);
|
||||
}
|
||||
|
||||
module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook };
|
||||
module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook };
|
||||
|
||||
@@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) {
|
||||
player: input.player || '',
|
||||
team: input.team || '',
|
||||
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),
|
||||
line,
|
||||
side,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
* 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%.
|
||||
* The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
|
||||
* requireable by the plain-JS Jest suite AND importable here (allowJs) — same
|
||||
* single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
|
||||
* 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:
|
||||
* 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer,
|
||||
* where API-Football returns the photo in player responses), use
|
||||
* that directly. We DO NOT construct soccer URLs because no
|
||||
* central CDN exists for player headshots.
|
||||
* 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
|
||||
* soccer URLs (no central CDN for player headshots).
|
||||
* 2. League CDN with `playerId` — official source.
|
||||
* 3. ESPN CDN fallback — only when `espnId` is present and no
|
||||
* league ID is available. Helpful while the roster table is
|
||||
* being backfilled with league-specific IDs.
|
||||
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette.
|
||||
*
|
||||
* `<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.
|
||||
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
|
||||
* 4. `/images/player-silhouette.svg` — the sentinel the consuming component
|
||||
* swaps for a team-colored MONOGRAM (never a gray blob, never a broken
|
||||
* image). `<img onError>` also degrades a 404 to the monogram.
|
||||
*/
|
||||
|
||||
import {
|
||||
getHeadshotUrl as coreGetHeadshotUrl,
|
||||
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
|
||||
} from '@/lib/playerHeadshotUrl';
|
||||
|
||||
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
|
||||
|
||||
export interface HeadshotInput {
|
||||
sport: HeadshotSport;
|
||||
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
|
||||
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;
|
||||
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
|
||||
cachedPhotoUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
|
||||
|
||||
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 const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
|
||||
|
||||
export function getHeadshotUrl(input: HeadshotInput): string {
|
||||
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;
|
||||
return coreGetHeadshotUrl(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for the common case where the caller has a
|
||||
* player object with mixed ID fields. Pulls the first non-empty ID
|
||||
* out of the union before delegating to `getHeadshotUrl`.
|
||||
* Convenience wrapper for the common case where the caller has a player object
|
||||
* with mixed ID fields. Pulls the first non-empty ID out of the union before
|
||||
* delegating to `getHeadshotUrl`.
|
||||
*/
|
||||
export function headshotFromPlayer(player: {
|
||||
sport?: string;
|
||||
|
||||
@@ -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 };
|
||||
@@ -35,6 +35,10 @@ const OPEN_ROUTES = [
|
||||
'/dashboard',
|
||||
'/slate',
|
||||
'/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',
|
||||
'/game',
|
||||
'/pricing',
|
||||
|
||||
@@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
player: displayName(p.player),
|
||||
team: knownTeam,
|
||||
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),
|
||||
injury: injuryFor(pk),
|
||||
stats: [],
|
||||
@@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
const cand = displayName(p.player);
|
||||
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 };
|
||||
// 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) {
|
||||
const side = sideCh(rec.direction);
|
||||
@@ -596,6 +603,7 @@ module.exports = {
|
||||
isRelevantGame,
|
||||
indexGrades,
|
||||
indexDeltas,
|
||||
gradeKey,
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
|
||||
@@ -195,7 +195,16 @@ function resolveTeam(key, sport) {
|
||||
const up = String(key).toUpperCase().trim();
|
||||
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
||||
// 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 (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
||||
const a = SOCCER_ALIAS[normKey(key)];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -25,6 +25,9 @@ const SPORT = {
|
||||
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
|
||||
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
||||
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 80–120px (§5: grade letter is
|
||||
|
||||