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|POST /api/profiles/me` (requireAuth)
|
||||||
GET → `{ profile: { handle, published, created_at } | null }`.
|
GET → `{ profile: { handle, published, created_at } | null }`.
|
||||||
POST `{ handle, published }` → upsert own row (service role). Handle must
|
POST `{ handle, published }` → upsert own row (service role). Handle must
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# VYNDR — STATE OF THE WORLD
|
# VYNDR — STATE OF THE WORLD
|
||||||
### As of `e9c0a59` (main, 2026-07-13). This file opens every future session. Update it when a train ships.
|
### As of `e9c0a59` (main) + `wiring/data-train` READY, 2026-07-13. This file opens every future session. Update it when a train ships.
|
||||||
|
|
||||||
|
## READY TO MERGE — WIRING & DATA TRAIN (branch `wiring/data-train`, NOT on main yet)
|
||||||
|
Governed by `specs/wiring-data-train.md` (the Step-0 MAP + build plans) + `specs/combat-intelligence.md` + the global visual reference `specs/design-reference/vyndr-system.html` (build toward it; live wordmark kept). **All 6 waves DONE, merged onto `wiring/data-train`, green (253 suites / 3069 tests) + `next build` exit 0. Awaiting the founder's word before main.** Real assets verified live (MLB/ESPN headshot CDNs, ESPN-MMA feed, Baseball Savant CSV).
|
||||||
|
- **Wave 1 — trust bugs:** billing renewal honest render (`billingDisplay.classifyRenewal` — no far-future placeholder); James Wood nameKey-collision fixed (`mlbStatsAdapter` teamHint disambiguation + streaks join-invariant); DeskShowcase "$1M terminal" → deadpan copy.
|
||||||
|
- **Wave 2 — sport-agnostic entity layer:** real player headshots threaded from ingestion (MLB MLBAM + NBA/WNBA ESPN athlete ids that were fetched-and-discarded) across slate/scan/hotlist/search/grade card; soccer = honest monogram (no free id); 8 self-authored SVG book wordmarks (`web/public/books/*.svg`, swappable for official art) + all 10 book keys resolve; team-logo abbr aliases. Storage: id on the `enriched` grade at `snapshotService` (zero new I/O).
|
||||||
|
- **Wave 3 — record by grade tier (Addition 2):** ONE shared `TierRecord` (`lib/tierRecord.js` + component) on dashboard + /u + ledger; per-tier W-L always, hit-% only at n≥20 per tier (gate stays in `getModelAggregate`).
|
||||||
|
- **Wave 4 — missing surfaces:** Outlook mode (grid never blank → yesterday receipts / tomorrow schedule); Market-Breadth median-consensus-vs-model strip (self-hides <2 books); Parlay Lab `/parlay` (slate-independent leg source); live Grade-Shift timeline (`GradeShift`).
|
||||||
|
- **Wave 5 — /u house-mode + arsenal:** house handle `vyndr` surfaces the real `user_id=NULL` public model record + per-tier calibration + 1080×1350 portrait/OG (user-handle privacy 404s stay byte-identical); Baseball Savant pitcher-arsenal (`savantAdapter`, free CSV, verified) → `PitcherArsenal` card, self-hides on absent.
|
||||||
|
- **Wave 6 — combat v1 (MMA):** ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes (sport-scoped `COMBAT_ARCHETYPES` — FINISHER color collided w/ soccer + tripped the ΔE gate, so kept separate) + odds-api ML/round-totals + style-edge verdict. NOT in the snapshot/settle loop; method/round/props + fighter photos + matchup-GRADE engine + ufcstats scraping all DEFERRED, flagged data-limited in-UI. New routes: `/fight/[id]`, `/parlay`, `/u/[handle]/portrait`.
|
||||||
|
POST-MERGE TODO: NBA/WNBA headshot coverage + combat depth need prod runtime verification; soccer headshots blocked on `API_FOOTBALL_KEY`; combat settlement + matchup-grade engine are the next combat sub-wave.
|
||||||
|
|
||||||
## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live)
|
## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live)
|
||||||
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
|
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
|
||||||
|
|||||||
@@ -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.
|
// stat-filtered views over all of them.
|
||||||
const scheduleRoutes = require('./routes/schedule');
|
const scheduleRoutes = require('./routes/schedule');
|
||||||
app.use('/api/schedule', scheduleRoutes);
|
app.use('/api/schedule', scheduleRoutes);
|
||||||
|
// Wave 6 — combat intelligence (MMA/UFC): fight cards + tale-of-the-tape +
|
||||||
|
// best-effort ML/round-total odds. Read-only, cache-friendly, honest empty
|
||||||
|
// off-card. NOT in the graded-props pipeline (no settled grades in v1).
|
||||||
|
const combatRoutes = require('./routes/combat');
|
||||||
|
app.use('/api/combat', combatRoutes);
|
||||||
|
app.use('/api/fight', combatRoutes.fightRouter);
|
||||||
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
|
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
|
||||||
// cache-only, never triggers a snapshot.
|
// cache-only, never triggers a snapshot.
|
||||||
const tickerRoutes = require('./routes/ticker');
|
const tickerRoutes = require('./routes/ticker');
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ const SPORTS = Object.freeze({
|
|||||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
// Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
|
||||||
|
// tale-of-the-tape + style blend + ML/round-total odds). It is NOT in the
|
||||||
|
// graded-props pipeline (SPORT_CONFIG) or the snapshot/settle loop yet, so
|
||||||
|
// collectData stays false — active flips true so the UI treats it as live.
|
||||||
|
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
|
||||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
|
||||||
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
|
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
|
||||||
* return the byte-identical 404 body.
|
* return the byte-identical 404 body.
|
||||||
|
*
|
||||||
|
* HOUSE PROFILE (Wave 5A, D2) — the reserved `HOUSE_HANDLE` (default `vyndr`)
|
||||||
|
* resolves to the PUBLIC model record (`getModelAggregate()` with NO userId →
|
||||||
|
* the `user_id = NULL` ledger rows). It needs NO public_profiles row and is
|
||||||
|
* ALWAYS public (it's the partner-pitch weapon: the real house record). It is
|
||||||
|
* the ONLY special case; every OTHER handle keeps the private-by-default,
|
||||||
|
* no-existence-leak contract intact.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -30,6 +37,10 @@ const router = express.Router();
|
|||||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||||
|
|
||||||
const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
|
const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
|
||||||
|
// Reserved house handle → the public model record (user_id = NULL). Operators
|
||||||
|
// can override via env; it must still satisfy HANDLE_RE to be reachable.
|
||||||
|
const HOUSE_HANDLE = String(process.env.HOUSE_HANDLE || 'vyndr').trim().toLowerCase();
|
||||||
|
const HOUSE_LABEL = 'VYNDR MODEL · PUBLIC RECORD';
|
||||||
// Same 404 body for unknown AND unpublished — never confirm a handle exists.
|
// Same 404 body for unknown AND unpublished — never confirm a handle exists.
|
||||||
const NOT_FOUND = { error: 'Profile not found' };
|
const NOT_FOUND = { error: 'Profile not found' };
|
||||||
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
|
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
|
||||||
@@ -94,6 +105,10 @@ router.get('/:handle', async (req, res) => {
|
|||||||
const handle = String(req.params.handle || '').trim().toLowerCase();
|
const handle = String(req.params.handle || '').trim().toLowerCase();
|
||||||
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
|
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
|
||||||
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
|
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
|
||||||
|
// HOUSE handle → the public model record. Checked BEFORE the
|
||||||
|
// public_profiles lookup so the handle is reserved (a user who claims it is
|
||||||
|
// shadowed). This is the ONLY handle that bypasses the publish gate.
|
||||||
|
if (handle === HOUSE_HANDLE) return sendHouseProfile(res);
|
||||||
const sb = sbOrNull();
|
const sb = sbOrNull();
|
||||||
if (!sb) return res.status(404).json(NOT_FOUND);
|
if (!sb) return res.status(404).json(NOT_FOUND);
|
||||||
try {
|
try {
|
||||||
@@ -131,4 +146,52 @@ router.get('/:handle', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The house/model profile — the PUBLIC model record (user_id = NULL), served
|
||||||
|
* as a shareable /u profile WITHOUT a public_profiles row. Same response shape
|
||||||
|
* the page already consumes (aggregate + by_tier + settled entries), plus
|
||||||
|
* `house: true` + a label so the UI can distinguish it from a user profile.
|
||||||
|
* It always "exists" → a fetch failure degrades to an honest empty/building
|
||||||
|
* state, never a 404.
|
||||||
|
*/
|
||||||
|
async function sendHouseProfile(res) {
|
||||||
|
const sb = sbOrNull();
|
||||||
|
try {
|
||||||
|
// NO userId → the public `user_id = NULL` aggregate (the real house
|
||||||
|
// record + Wave-3 by_tier). getModelAggregate self-empties without env.
|
||||||
|
const aggregate = await ledgerService.getModelAggregate(sb ? { sb } : {});
|
||||||
|
let entries = [];
|
||||||
|
if (sb) {
|
||||||
|
// ALL public settled reads, misses included — nothing curated.
|
||||||
|
const { data, error } = await sb.from('ledger_entries')
|
||||||
|
.select(ROW_COLUMNS)
|
||||||
|
.is('user_id', null)
|
||||||
|
.not('outcome', 'is', null)
|
||||||
|
.order('graded_at', { ascending: false })
|
||||||
|
.limit(ENTRY_LIMIT);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
entries = data || [];
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'public, max-age=60');
|
||||||
|
return res.json({
|
||||||
|
handle: HOUSE_HANDLE,
|
||||||
|
house: true,
|
||||||
|
label: HOUSE_LABEL,
|
||||||
|
aggregate,
|
||||||
|
entries,
|
||||||
|
min_sample: ledgerService.MIN_AGG_SAMPLE,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[profiles/house]', err.message);
|
||||||
|
return res.status(200).json({
|
||||||
|
handle: HOUSE_HANDLE,
|
||||||
|
house: true,
|
||||||
|
label: HOUSE_LABEL,
|
||||||
|
aggregate: null,
|
||||||
|
entries: [],
|
||||||
|
min_sample: ledgerService.MIN_AGG_SAMPLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const express = require('express');
|
|||||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||||
const { getStatFilters } = require('../config/statFilters');
|
const { getStatFilters } = require('../config/statFilters');
|
||||||
const { createRateLimit } = require('../middleware/rateLimit');
|
const { createRateLimit } = require('../middleware/rateLimit');
|
||||||
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
|
const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService');
|
||||||
|
const { getPitcherArsenal } = require('../services/adapters/savantAdapter');
|
||||||
const depthChart = require('../services/depthChartService');
|
const depthChart = require('../services/depthChartService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B).
|
||||||
|
// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast
|
||||||
|
// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other
|
||||||
|
// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates.
|
||||||
|
router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sport = String(req.query.sport || 'mlb').toLowerCase();
|
||||||
|
if (sport !== 'mlb') {
|
||||||
|
return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' });
|
||||||
|
}
|
||||||
|
const name = sanitizePlayerName(req.params.name);
|
||||||
|
const arsenal = await getPitcherArsenal(name);
|
||||||
|
res.set(MISSION_HEADER).json(arsenal || { found: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats/pitcher/arsenal]', err.message);
|
||||||
|
// Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides.
|
||||||
|
res.set(MISSION_HEADER).json({ found: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
|
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
|
||||||
// graded props by confidence) for the Terminal / Stats Explorer.
|
// graded props by confidence) for the Terminal / Stats Explorer.
|
||||||
router.get('/leaders', intelLimit, async (req, res) => {
|
router.get('/leaders', intelLimit, async (req, res) => {
|
||||||
|
|||||||
@@ -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));
|
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
|
||||||
if (!id) return { found: false };
|
if (!id) return { found: false };
|
||||||
|
|
||||||
|
// Wave 2A — the REAL ESPN athlete id for the headshot CDN
|
||||||
|
// (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
|
||||||
|
// pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
|
||||||
|
// id, so it degrades to null → monogram. Never fabricate.
|
||||||
|
const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
|
||||||
|
const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;
|
||||||
|
|
||||||
// 2. Fetch that athlete's stats overview.
|
// 2. Fetch that athlete's stats overview.
|
||||||
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
|
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
|
||||||
const classifierInput = parseAthleteStats(stats);
|
const classifierInput = parseAthleteStats(stats);
|
||||||
@@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) {
|
|||||||
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
|
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
|
||||||
position: (athlete.position && athlete.position.abbreviation) || '',
|
position: (athlete.position && athlete.position.abbreviation) || '',
|
||||||
classifierInput,
|
classifierInput,
|
||||||
|
// Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
|
||||||
|
// slate strip → headshot. Absent → monogram (doctrine).
|
||||||
|
espnId,
|
||||||
};
|
};
|
||||||
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
|
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) {
|
|||||||
return matchPlayers(people, query, opts.limit || 12);
|
return matchPlayers(people, query, opts.limit || 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchPlayer(name, season = DEFAULT_SEASON) {
|
// ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
|
||||||
|
// 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);
|
const targetKey = nameKey(name);
|
||||||
if (!targetKey) return null;
|
if (!targetKey) return null;
|
||||||
const url = `${BASE}/sports/1/players?season=${season}`;
|
|
||||||
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
|
let people = opts.people;
|
||||||
const people = (data && Array.isArray(data.people)) ? data.people : [];
|
if (!Array.isArray(people)) {
|
||||||
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
|
const url = `${BASE}/sports/1/players?season=${season}`;
|
||||||
if (!hit) {
|
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
|
||||||
const parts = targetKey.split(' ');
|
people = (data && Array.isArray(data.people)) ? data.people : [];
|
||||||
const first = parts[0] || '';
|
}
|
||||||
const last = parts[parts.length - 1] || '';
|
|
||||||
if (first && last && first !== last) {
|
const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null;
|
||||||
const cands = people.filter((p) => {
|
let teams = Array.isArray(opts.teams) ? opts.teams : null;
|
||||||
const k = nameKey(p.fullName).split(' ');
|
const ensureTeams = async () => {
|
||||||
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
|
if (teams) return teams;
|
||||||
});
|
try { teams = await getTeams(season); } catch { teams = []; }
|
||||||
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess
|
return teams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exact = people.filter((p) => nameKey(p.fullName) === targetKey);
|
||||||
|
let hit = null;
|
||||||
|
let teamConfirmed = true; // stays true when there's no hint to check against
|
||||||
|
|
||||||
|
if (exact.length === 1) {
|
||||||
|
hit = exact[0];
|
||||||
|
if (teamHint && hit.currentTeam) {
|
||||||
|
teamConfirmed = teamRecordMatchesHint(
|
||||||
|
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
|
||||||
|
}
|
||||||
|
} else if (exact.length >= 2) {
|
||||||
|
// Namesake collision — resolve ONLY with a confident hint, else refuse.
|
||||||
|
hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null;
|
||||||
|
// a hit here is team-confirmed by construction.
|
||||||
|
} else {
|
||||||
|
hit = lastNameInitialFallback(people, targetKey);
|
||||||
|
if (hit && teamHint && hit.currentTeam) {
|
||||||
|
teamConfirmed = teamRecordMatchesHint(
|
||||||
|
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
return {
|
return {
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
fullName: hit.fullName ?? name,
|
fullName: hit.fullName ?? name,
|
||||||
team: hit.currentTeam?.name ?? null,
|
team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
|
||||||
teamId: hit.currentTeam?.id ?? null,
|
teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null,
|
||||||
position: hit.primaryPosition?.abbreviation ?? null,
|
position: hit.primaryPosition?.abbreviation ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) {
|
|||||||
* last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
|
* last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
|
||||||
* { found: false } on any miss/failure (never throws).
|
* { found: false } on any miss/failure (never throws).
|
||||||
*/
|
*/
|
||||||
async function getPlayerStats(name, season = DEFAULT_SEASON) {
|
async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
|
||||||
try {
|
try {
|
||||||
const person = await searchPlayer(name, season);
|
const person = await searchPlayer(name, season, opts);
|
||||||
if (!person) return { found: false };
|
if (!person) return { found: false };
|
||||||
const group = person.position === 'P' ? 'pitching' : 'hitting';
|
const group = person.position === 'P' ? 'pitching' : 'hitting';
|
||||||
const [seasonStat, log] = await Promise.all([
|
const [seasonStat, log] = await Promise.all([
|
||||||
@@ -293,5 +394,9 @@ module.exports = {
|
|||||||
getTeams,
|
getTeams,
|
||||||
resolveTeam,
|
resolveTeam,
|
||||||
getTeamRoster,
|
getTeamRoster,
|
||||||
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
|
__internals: {
|
||||||
|
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
|
||||||
|
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
|
||||||
|
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
|
||||||
|
// Strict presence check — an ABSENT stat must not score an axis (Number(null)
|
||||||
|
// === 0 would fabricate a "0 output" claim). Only a real finite number counts.
|
||||||
|
const has = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||||
|
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||||
|
|
||||||
/** NBA scorers — VYNDR Original keys. */
|
/** NBA scorers — VYNDR Original keys. */
|
||||||
function scoreNBA(s) {
|
function scoreNBA(s) {
|
||||||
@@ -358,7 +391,76 @@ function scoreMLB(s) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
|
/**
|
||||||
|
* MMA fighter scorer. Style is a BLEND across three axes:
|
||||||
|
* range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs)
|
||||||
|
* tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense)
|
||||||
|
* outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance)
|
||||||
|
*
|
||||||
|
* Best-effort from what the ESPN feed exposes; ESPN's striking/grappling
|
||||||
|
* granularity is THINNER than ufcstats. Every axis scores ONLY when its
|
||||||
|
* inputs are real finite numbers — thin data yields fewer style claims,
|
||||||
|
* never a fabricated one. Inputs (all optional):
|
||||||
|
* slpm/sapm sig strikes landed/absorbed per min
|
||||||
|
* strAcc/strDef striking accuracy / defense (0-1)
|
||||||
|
* tdAvg takedowns per 15
|
||||||
|
* subAvg sub attempts per 15
|
||||||
|
* koRate/subRate/decRate fraction of WINS by method (0-1)
|
||||||
|
* koWins/subWins/decWins method counts (rates derived if rates absent)
|
||||||
|
*/
|
||||||
|
function scoreMMA(s = {}) {
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
// ── range axis ──
|
||||||
|
if (has(s.slpm)) {
|
||||||
|
let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite
|
||||||
|
if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker
|
||||||
|
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15;
|
||||||
|
if (v > 0) out.STRIKER = clamp01(v);
|
||||||
|
}
|
||||||
|
if (has(s.tdAvg) || has(s.subAvg)) {
|
||||||
|
let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control
|
||||||
|
if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5;
|
||||||
|
if (v > 0) out.GRAPPLER = clamp01(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tempo axis ──
|
||||||
|
if (has(s.slpm) && has(s.sapm)) {
|
||||||
|
const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure
|
||||||
|
if (vol > 0) out.PRESSURE = vol;
|
||||||
|
}
|
||||||
|
if (has(s.strDef) || has(s.strAcc)) {
|
||||||
|
let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0;
|
||||||
|
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4;
|
||||||
|
if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter
|
||||||
|
if (v > 0) out.COUNTER = clamp01(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── outcome axis ──
|
||||||
|
const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s);
|
||||||
|
const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s);
|
||||||
|
const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s);
|
||||||
|
if (koR != null || subR != null) {
|
||||||
|
const finish = (koR || 0) + (subR || 0);
|
||||||
|
if (finish > 0) out.FINISHER = clamp01(finish);
|
||||||
|
}
|
||||||
|
if (decR != null && decR > 0) out.GRINDER = clamp01(decR);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a method rate from a win count when explicit rates are absent.
|
||||||
|
// Returns null (not 0) when totals are unknown — absent, never fabricated.
|
||||||
|
function deriveRate(count, s) {
|
||||||
|
if (!has(count)) return null;
|
||||||
|
const total = has(s.totalWins)
|
||||||
|
? s.totalWins
|
||||||
|
: (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0);
|
||||||
|
if (!total || total <= 0) return null;
|
||||||
|
return clamp01(count / total);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA };
|
||||||
|
|
||||||
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
|
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
|
||||||
function getArchetype(name) {
|
function getArchetype(name) {
|
||||||
@@ -370,6 +472,13 @@ function getArchetype(name) {
|
|||||||
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
|
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Look up a COMBAT archetype descriptor by name (case-insensitive). */
|
||||||
|
function getCombatArchetype(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
const key = String(name).toUpperCase();
|
||||||
|
return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a player. Returns:
|
* Classify a player. Returns:
|
||||||
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
||||||
@@ -384,7 +493,12 @@ function classify(sport, stats = {}) {
|
|||||||
.filter(([, v]) => v > 0)
|
.filter(([, v]) => v > 0)
|
||||||
.sort((a, b) => b[1] - a[1]);
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
// Combat is HONEST-empty on thin data: no forced fallback archetype (the
|
||||||
|
// other sports fall back to a low-usage role, but inventing a fighter's
|
||||||
|
// style from no data would be a fabrication — spec §STYLE-MATCHUP).
|
||||||
|
const resolver = sp === 'mma' ? getCombatArchetype : getArchetype;
|
||||||
if (ranked.length === 0) {
|
if (ranked.length === 0) {
|
||||||
|
if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
|
||||||
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
|
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
|
||||||
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
|
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
|
||||||
}
|
}
|
||||||
@@ -393,23 +507,88 @@ function classify(sport, stats = {}) {
|
|||||||
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
|
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
|
||||||
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
|
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
|
||||||
|
|
||||||
const primary = getArchetype(ranked[0][0]);
|
const primary = resolver(ranked[0][0]);
|
||||||
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
|
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
|
||||||
? getArchetype(ranked[1][0])
|
? resolver(ranked[1][0])
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return { sport: sp, primary, secondary, blend };
|
return { sport: sp, primary, secondary, blend };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Weight of an archetype within a blend (0 when absent). */
|
||||||
|
function blendWeight(blend, name) {
|
||||||
|
const hit = (blend || []).find((b) => b.archetype === name);
|
||||||
|
return hit ? hit.weight : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either a classify() result ({ blend }) or a raw blend array.
|
||||||
|
function asBlend(x) {
|
||||||
|
if (Array.isArray(x)) return x;
|
||||||
|
if (x && Array.isArray(x.blend)) return x.blend;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's
|
||||||
|
* CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated
|
||||||
|
* confidence. Compares two style blends; when the data is too thin or the
|
||||||
|
* styles are too close to call, it says so honestly.
|
||||||
|
*
|
||||||
|
* a/b may be classify('mma', …) results or raw blend arrays.
|
||||||
|
* Returns { verdict, edgeSide: 'a'|'b'|null, summary }.
|
||||||
|
*/
|
||||||
|
const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER'];
|
||||||
|
const MIN_EDGE = 0.2; // below this stylistic gap → too close to call
|
||||||
|
|
||||||
|
function styleMatchup(a, b) {
|
||||||
|
const A = asBlend(a);
|
||||||
|
const B = asBlend(b);
|
||||||
|
if (A.length === 0 || B.length === 0) {
|
||||||
|
return {
|
||||||
|
verdict: 'INSUFFICIENT READ',
|
||||||
|
edgeSide: null,
|
||||||
|
summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const ax of STYLE_AXES) {
|
||||||
|
const diff = blendWeight(A, ax) - blendWeight(B, ax);
|
||||||
|
if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best || Math.abs(best.diff) < MIN_EDGE) {
|
||||||
|
return {
|
||||||
|
verdict: 'STYLES EVEN',
|
||||||
|
edgeSide: null,
|
||||||
|
summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeSide = best.diff > 0 ? 'a' : 'b';
|
||||||
|
return {
|
||||||
|
verdict: `${best.ax} EDGE`,
|
||||||
|
edgeSide,
|
||||||
|
summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const classifyNBA = (stats) => classify('nba', stats);
|
const classifyNBA = (stats) => classify('nba', stats);
|
||||||
const classifyWNBA = (stats) => classify('wnba', stats);
|
const classifyWNBA = (stats) => classify('wnba', stats);
|
||||||
const classifyMLB = (stats) => classify('mlb', stats);
|
const classifyMLB = (stats) => classify('mlb', stats);
|
||||||
|
|
||||||
|
const classifyMMA = (stats) => classify('mma', stats);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ARCHETYPES,
|
ARCHETYPES,
|
||||||
|
COMBAT_ARCHETYPES,
|
||||||
|
DISCIPLINE_PEDIGREES,
|
||||||
getArchetype,
|
getArchetype,
|
||||||
|
getCombatArchetype,
|
||||||
classify,
|
classify,
|
||||||
classifyNBA,
|
classifyNBA,
|
||||||
classifyWNBA,
|
classifyWNBA,
|
||||||
classifyMLB,
|
classifyMLB,
|
||||||
|
classifyMMA,
|
||||||
|
styleMatchup,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const SPORT_KEYS = {
|
|||||||
// keys added in Session 31; NHL keys were added alongside this wiring.
|
// keys added in Session 31; NHL keys were added alongside this wiring.
|
||||||
nfl: 'americanfootball_nfl',
|
nfl: 'americanfootball_nfl',
|
||||||
nhl: 'icehockey_nhl',
|
nhl: 'icehockey_nhl',
|
||||||
|
// MMA / UFC (Wave 6 — combat intelligence). odds-api sport key for the
|
||||||
|
// moneyline + round-total feed. PropLine carries NO combat → odds-api-only
|
||||||
|
// (combat never enters the abundant player-props path). Off-card windows
|
||||||
|
// return an empty events array and the combat surface self-hides honestly.
|
||||||
|
mma: 'mma_mixed_martial_arts',
|
||||||
// Soccer (Session 7j) — odds-api sport keys verified against
|
// Soccer (Session 7j) — odds-api sport keys verified against
|
||||||
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
||||||
soccer_wc: 'soccer_fifa_world_cup',
|
soccer_wc: 'soccer_fifa_world_cup',
|
||||||
@@ -142,6 +147,10 @@ const SOCCER_MARKETS = [
|
|||||||
'player_passes',
|
'player_passes',
|
||||||
'team_clean_sheet',
|
'team_clean_sheet',
|
||||||
];
|
];
|
||||||
|
// MMA / UFC (Wave 6) — GAME-level markets only: moneyline (h2h) + round total
|
||||||
|
// (totals). No player props on the free feed → NO 'spreads' suffix (unlike the
|
||||||
|
// player-prop sports, whose buildMarketString appends spreads).
|
||||||
|
const MMA_MARKETS = ['h2h', 'totals'];
|
||||||
|
|
||||||
function buildMarketString(markets) {
|
function buildMarketString(markets) {
|
||||||
return [...markets, 'spreads'].join(',');
|
return [...markets, 'spreads'].join(',');
|
||||||
@@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({
|
|||||||
mlb: buildMarketString(MLB_MARKETS),
|
mlb: buildMarketString(MLB_MARKETS),
|
||||||
nfl: buildMarketString(NFL_MARKETS),
|
nfl: buildMarketString(NFL_MARKETS),
|
||||||
nhl: buildMarketString(NHL_MARKETS),
|
nhl: buildMarketString(NHL_MARKETS),
|
||||||
|
// MMA carries no player-prop spreads → join the game-level markets directly.
|
||||||
|
mma: MMA_MARKETS.join(','),
|
||||||
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
|
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
|
||||||
// Every soccer league code shares the same market set.
|
// Every soccer league code shares the same market set.
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
@@ -499,6 +510,8 @@ module.exports = {
|
|||||||
getCacheKey,
|
getCacheKey,
|
||||||
SPORT_KEYS,
|
SPORT_KEYS,
|
||||||
SOCCER_SPORT_KEYS,
|
SOCCER_SPORT_KEYS,
|
||||||
|
// Wave 6 — combat game-level markets (moneyline + round total).
|
||||||
|
MMA_MARKETS,
|
||||||
// Session 16 — per-sport market scoping.
|
// Session 16 — per-sport market scoping.
|
||||||
SPORT_MARKETS,
|
SPORT_MARKETS,
|
||||||
getMarketsForSport,
|
getMarketsForSport,
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
|||||||
try {
|
try {
|
||||||
if (sp === 'mlb') {
|
if (sp === 'mlb') {
|
||||||
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
|
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
|
||||||
const res = await mlb.getPlayerStats(name);
|
// Wave 1 — thread the prop's game team hint so a namesake collision
|
||||||
|
// (two "James Wood") resolves to the RIGHT player, and a team that isn't
|
||||||
|
// a participant of the prop's game is dropped (never a foreign tag).
|
||||||
|
const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
|
||||||
if (!res || !res.found) return { found: false };
|
if (!res || !res.found) return { found: false };
|
||||||
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
|
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
|
||||||
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
|
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
|
||||||
@@ -162,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
|||||||
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
|
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
|
||||||
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
|
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
|
||||||
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
|
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
|
||||||
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] };
|
// Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
|
||||||
|
// adapter. Absent → no id → monogram. Never guessed.
|
||||||
|
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
|
||||||
}
|
}
|
||||||
return { found: false };
|
return { found: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,25 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
|
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
|
||||||
// and the slate join guard (a prop only attaches to its own game).
|
// and the slate join guard (a prop only attaches to its own game).
|
||||||
const teamByPlayer = {};
|
const teamByPlayer = {};
|
||||||
|
// Wave 2A — the REAL athlete id from the SAME stats resolve, keyed by player.
|
||||||
|
// MLB → MLBAM id (mlbstatic headshot CDN); NBA/WNBA → ESPN athlete id
|
||||||
|
// (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to
|
||||||
|
// grades:{sport} → slate strips → PlayerAvatar. Zero new I/O. Absent → the
|
||||||
|
// component falls to a team-colored monogram (never a fabricated face).
|
||||||
|
const playerIdByPlayer = {};
|
||||||
|
const espnIdByPlayer = {};
|
||||||
|
// Wave 1 (trust bug) — the prop's game participants become the resolve's
|
||||||
|
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when
|
||||||
|
// the resolved player's real team isn't in the prop's game, the resolver drops
|
||||||
|
// the team rather than tag a foreign one (the streaks/rosterlogs JOIN
|
||||||
|
// INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name.
|
||||||
|
const teamHintByPlayer = {};
|
||||||
|
for (const p of props || []) {
|
||||||
|
const k = norm(p.player);
|
||||||
|
if (!k || teamHintByPlayer[k]) continue;
|
||||||
|
const hint = [p.home_team, p.away_team].filter(Boolean);
|
||||||
|
if (hint.length) teamHintByPlayer[k] = hint;
|
||||||
|
}
|
||||||
// Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks +
|
// Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks +
|
||||||
// hot lists) starved because its data producers were all external and
|
// hot lists) starved because its data producers were all external and
|
||||||
// unarmed (tank01-prefetch via n8n, the offline Python grading flow).
|
// unarmed (tank01-prefetch via n8n, the offline Python grading flow).
|
||||||
@@ -308,11 +327,15 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
const logEntries = [];
|
const logEntries = [];
|
||||||
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
|
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
|
||||||
try {
|
try {
|
||||||
const stats = await deps.resolveStats(player, sp);
|
const teamHint = teamHintByPlayer[norm(player)] || null;
|
||||||
|
const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {});
|
||||||
if (stats && stats.found) {
|
if (stats && stats.found) {
|
||||||
const c = deps.classify(sp, stats.classifierInput || {});
|
const c = deps.classify(sp, stats.classifierInput || {});
|
||||||
archByPlayer[player] = c.primary ? c.primary.name : null;
|
archByPlayer[player] = c.primary ? c.primary.name : null;
|
||||||
if (stats.team) teamByPlayer[player] = stats.team;
|
if (stats.team) teamByPlayer[player] = stats.team;
|
||||||
|
// Wave 2A — capture the resolved athlete id (headshot thread).
|
||||||
|
if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId;
|
||||||
|
if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId;
|
||||||
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
|
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
|
||||||
logEntries.push({
|
logEntries.push({
|
||||||
name: normalizeName(player).display || player,
|
name: normalizeName(player).display || player,
|
||||||
@@ -328,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
});
|
});
|
||||||
await mergeRosterLogs(sp, logEntries, deps);
|
await mergeRosterLogs(sp, logEntries, deps);
|
||||||
|
|
||||||
const enriched = graded.map((g) => ({
|
const enriched = graded.map((g) => {
|
||||||
...g,
|
const pn = g.player || g.player_name;
|
||||||
gradedAt: gradedAtFor(g, oddsByKey, ts),
|
return {
|
||||||
archetype: archByPlayer[g.player || g.player_name] || null,
|
...g,
|
||||||
team: teamByPlayer[g.player || g.player_name] || g.team || null,
|
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.
|
// Line deltas vs the previous snapshot's locked lines.
|
||||||
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
|
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ const MARKET_MAP = {
|
|||||||
// are shared with soccer/NBA — sport context discriminates downstream.
|
// are shared with soccer/NBA — sport context discriminates downstream.
|
||||||
player_shots_on_goal: 'shots_on_goal',
|
player_shots_on_goal: 'shots_on_goal',
|
||||||
goalie_saves: 'saves',
|
goalie_saves: 'saves',
|
||||||
|
|
||||||
|
// MMA / UFC (Wave 6 — combat intelligence). Game-level markets, NOT player
|
||||||
|
// props: h2h = moneyline (per fighter), totals = round total (over/under).
|
||||||
|
// Mapped here so combat odds don't silently normalize to zero when wired
|
||||||
|
// through the odds-api path (same silent-failure class as the MLB/NHL gaps).
|
||||||
|
// The combat surface parses these via combatAdapter.normalizeCombatOdds —
|
||||||
|
// normalizeProps (this file) is player-prop-shaped and skips them, which is
|
||||||
|
// correct: MMA carries no per-player point props on the free feed.
|
||||||
|
h2h: 'moneyline',
|
||||||
|
totals: 'round_total',
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeProps(eventsWithOdds) {
|
function normalizeProps(eventsWithOdds) {
|
||||||
|
|||||||
@@ -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'");
|
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => {
|
test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => {
|
||||||
expect(page).toContain('import DeskShowcase');
|
expect(page).toContain('import DeskShowcase');
|
||||||
expect(page).toContain('<DeskShowcase');
|
expect(page).toContain('<DeskShowcase');
|
||||||
expect(showcase).toContain('$1M terminal');
|
// Wave 1 — the headline SHOWS what Desk does instead of claiming a dollar
|
||||||
|
// figure (VYNDR voice: understated, no hype, no "$1M"/"terminal"-as-brag).
|
||||||
|
expect(showcase).toContain('Every grade, every alt line, live.');
|
||||||
|
expect(showcase).not.toContain('$1M');
|
||||||
|
expect(showcase).not.toContain('1M terminal');
|
||||||
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
||||||
expect(showcase).toContain('$34.99'); // the founder price
|
expect(showcase).toContain('$34.99'); // the founder price
|
||||||
// real feature visuals fill the right half (kills the dead half, #8)
|
// real feature visuals fill the right half (kills the dead half, #8)
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ describe('resolveTeam — abbr / full name / nickname / alias', () => {
|
|||||||
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
||||||
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
|
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
|
||||||
});
|
});
|
||||||
|
test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => {
|
||||||
|
// NBA abbrs the ESPN schedule emits that used to fall to a monogram
|
||||||
|
expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs
|
||||||
|
expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks
|
||||||
|
expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards
|
||||||
|
expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets
|
||||||
|
// WNBA
|
||||||
|
expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun
|
||||||
|
expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics
|
||||||
|
});
|
||||||
|
test('global alias never shadows a real same-abbr team in another sport', () => {
|
||||||
|
// WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the
|
||||||
|
// NBA/WNBA WSH→WAS alias.
|
||||||
|
expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals');
|
||||||
|
// NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias.
|
||||||
|
expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty');
|
||||||
|
// NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed.
|
||||||
|
expect(resolveTeam('NY', 'mlb')).toBeNull();
|
||||||
|
});
|
||||||
test('unknown team → null (never a fake)', () => {
|
test('unknown team → null (never a fake)', () => {
|
||||||
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
||||||
expect(resolveTeam('', 'mlb')).toBeNull();
|
expect(resolveTeam('', 'mlb')).toBeNull();
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => {
|
|||||||
expect(r.found).toBe(true);
|
expect(r.found).toBe(true);
|
||||||
expect(r.team).toBe('DAL');
|
expect(r.team).toBe('DAL');
|
||||||
expect(r.classifierInput.ppg).toBe(33);
|
expect(r.classifierInput.ppg).toBe(33);
|
||||||
|
// Wave 2A — the REAL ESPN athlete id is surfaced (headshot CDN), not discarded.
|
||||||
|
expect(r.espnId).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wave 2A — a non-numeric uid degrades espnId to null (never fabricated)', async () => {
|
||||||
|
const http = {
|
||||||
|
get: async (url) => {
|
||||||
|
if (url.includes('/search')) return { data: { items: [{ uid: 's:40~l:46~a:999', displayName: 'X', team: {}, position: {} }] } };
|
||||||
|
return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 10 }] }] } } } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = await espn.getSeasonAverages('X', 'nba', { http });
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
expect(r.espnId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('degrades to found:false when ESPN errors', async () => {
|
it('degrades to found:false when ESPN errors', async () => {
|
||||||
@@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => {
|
|||||||
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
|
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
|
||||||
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
|
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
|
||||||
nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } },
|
nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } },
|
||||||
espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) },
|
espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 }, espnId: '3945274' }) },
|
||||||
});
|
});
|
||||||
expect(r.found).toBe(true);
|
expect(r.found).toBe(true);
|
||||||
expect(r.team).toBe('DAL');
|
expect(r.team).toBe('DAL');
|
||||||
expect(r.classifierInput.ppg).toBe(33);
|
expect(r.classifierInput.ppg).toBe(33);
|
||||||
|
// Wave 2A — espnId surfaces through resolvePlayerStats → the snapshot grade.
|
||||||
|
expect(r.espnId).toBe('3945274');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('found:false when both sources are empty', async () => {
|
it('found:false when both sources are empty', async () => {
|
||||||
|
|||||||
@@ -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.settled).toBe(3);
|
||||||
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
|
||||||
|
const sb = fakeSb();
|
||||||
|
const rows = [
|
||||||
|
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
|
||||||
|
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
|
||||||
|
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
|
||||||
|
];
|
||||||
|
sb._state.selectResults = [rows];
|
||||||
|
sb._state.countResult = 0;
|
||||||
|
const agg = await ledger.getModelAggregate({ sb });
|
||||||
|
// A+ is its own bucket — never merged into the first-letter A bucket.
|
||||||
|
expect(agg.by_tier['A+'].settled).toBe(5);
|
||||||
|
expect(agg.by_tier['A+'].hits).toBe(5);
|
||||||
|
// A + A- fold together by first letter (but NOT A+).
|
||||||
|
expect(agg.by_tier.A.settled).toBe(6);
|
||||||
|
expect(agg.by_tier.A.hits).toBe(4);
|
||||||
|
expect(agg.by_tier.A.misses).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session 61 — the odds index prefers a fully-priced book row (the null
|
// Session 61 — the odds index prefers a fully-priced book row (the null
|
||||||
|
|||||||
@@ -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');
|
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);
|
expect(cache.store['grades:mlb'].grades).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Wave 2A — threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => {
|
||||||
|
const cache = memCache();
|
||||||
|
const d = deps(cache);
|
||||||
|
// Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape).
|
||||||
|
d.resolveStats = async (player) => (player === 'Aaron Judge'
|
||||||
|
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 }
|
||||||
|
: { found: true, classifierInput: {}, espnId: 4433403 });
|
||||||
|
await svc.runSnapshot('mlb', d);
|
||||||
|
const snap = cache.store['snapshot:mlb:latest'];
|
||||||
|
const judge = snap.grades.find((g) => g.player === 'Aaron Judge');
|
||||||
|
const betts = snap.grades.find((g) => g.player === 'Mookie Betts');
|
||||||
|
expect(judge.playerId).toBe(592450);
|
||||||
|
expect(betts.espnId).toBe(4433403);
|
||||||
|
// grades:{sport} inherits the same ids (GameCard/Explore read from it).
|
||||||
|
const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge');
|
||||||
|
expect(g.playerId).toBe(592450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wave 2A — no resolved id → enriched grade carries null ids (monogram path)', async () => {
|
||||||
|
const cache = memCache();
|
||||||
|
const d = deps(cache);
|
||||||
|
d.resolveStats = async () => ({ found: false }); // nothing resolves
|
||||||
|
await svc.runSnapshot('mlb', d);
|
||||||
|
const snap = cache.store['snapshot:mlb:latest'];
|
||||||
|
expect(snap.grades[0].playerId).toBeNull();
|
||||||
|
expect(snap.grades[0].espnId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('rotates latest → previous and computes deltas on the second run', async () => {
|
it('rotates latest → previous and computes deltas on the second run', async () => {
|
||||||
const cache = memCache();
|
const cache = memCache();
|
||||||
let line = 1.5;
|
let line = 1.5;
|
||||||
|
|||||||
@@ -64,14 +64,23 @@ describe('SPORT_MARKETS — isolation', () => {
|
|||||||
expect(wc).not.toMatch(/batter_/);
|
expect(wc).not.toMatch(/batter_/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every market list ends with `spreads`', () => {
|
test('every PLAYER-PROP market list ends with `spreads`', () => {
|
||||||
for (const list of Object.values(SPORT_MARKETS)) {
|
for (const [sport, list] of Object.entries(SPORT_MARKETS)) {
|
||||||
|
// Wave 6 — MMA is a GAME-level sport (h2h + round totals only); it has
|
||||||
|
// no player-prop `spreads` market and odds-api 422s if one is sent.
|
||||||
|
if (sport === 'mma') continue;
|
||||||
// We don't require spreads to be the literal final segment,
|
// We don't require spreads to be the literal final segment,
|
||||||
// only that it's present in the comma-separated list.
|
// only that it's present in the comma-separated list.
|
||||||
expect(list.split(',')).toContain('spreads');
|
expect(list.split(',')).toContain('spreads');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MMA market list is game-level (h2h + totals), no spreads/player props', () => {
|
||||||
|
expect(SPORT_MARKETS.mma).toBe('h2h,totals');
|
||||||
|
expect(SPORT_MARKETS.mma).not.toMatch(/spreads/);
|
||||||
|
expect(SPORT_MARKETS.mma).not.toMatch(/player_/);
|
||||||
|
});
|
||||||
|
|
||||||
test('SPORT_MARKETS is frozen at the top level', () => {
|
test('SPORT_MARKETS is frozen at the top level', () => {
|
||||||
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// below as intelligence layers on top of the raw odds.
|
||||||
import Slate from '@/components/Slate';
|
import Slate from '@/components/Slate';
|
||||||
// Session 55 — the self-learning loop's track record, live in the header.
|
// Session 55 — the self-learning loop's track record, live in the header.
|
||||||
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
|
import { AccuracyBadge, Skeleton, SkeletonList, TierRecord } from '@/components/vyndr';
|
||||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
||||||
import { emptyStateCopy } from '@/lib/emptyState';
|
import { emptyStateCopy } from '@/lib/emptyState';
|
||||||
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
||||||
@@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
|||||||
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
|
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
|
||||||
// paint ALWAYS proves the model (#1, #13, Part 6).
|
// paint ALWAYS proves the model (#1, #13, Part 6).
|
||||||
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
|
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
|
||||||
|
// Wave 4A (Step 3) — OUTLOOK MODE: the "Today's games" empty branch is never a
|
||||||
|
// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule.
|
||||||
|
import { buildOutlook } from '@/lib/outlook';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import { currentAccessToken } from '@/lib/authToken';
|
import { currentAccessToken } from '@/lib/authToken';
|
||||||
|
|
||||||
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
|
|||||||
WNBA: '#FFB347',
|
WNBA: '#FFB347',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wave 4A (Step 3) — the dashboard OUTLOOK surface for an empty slate. Replaces
|
||||||
|
* the old "NO SLATE" dead-end CTA: the month-aware header + yesterday's PROVEN
|
||||||
|
* A-tier receipts + tomorrow's date-pinned schedule preview (all REAL,
|
||||||
|
* always-available data — never an invented line). Self-fetches both; the
|
||||||
|
* header always renders, so the grid is never blank.
|
||||||
|
*/
|
||||||
|
function DashboardOutlook({ sport }: { sport: Sport }) {
|
||||||
|
const [settled, setSettled] = useState<ModelEntry[] | null>(null);
|
||||||
|
const [tomorrow, setTomorrow] = useState<ScheduleApiGame[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
|
||||||
|
.catch(() => { if (active) setSettled([]); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [sport]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
// Tomorrow, ET — the schedule route is date-pinned (free / cached).
|
||||||
|
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(Date.now() + 86_400_000));
|
||||||
|
fetch(`/api/schedule/${sport.toLowerCase()}?date=${date}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => { if (active) setTomorrow((Array.isArray(d?.games) ? d.games : []).map((g: ScheduleApiGame) => ({ ...g, sport: sport.toLowerCase() }))); })
|
||||||
|
.catch(() => { if (active) setTomorrow([]); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [sport]);
|
||||||
|
|
||||||
|
const outlook = buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceiptView[]; tomorrow?: OutlookGameView[] };
|
||||||
|
const receipts: OutlookReceiptView[] = outlook.receipts ?? [];
|
||||||
|
const preview: OutlookGameView[] = outlook.tomorrow ?? [];
|
||||||
|
const { title, body } = emptyStateCopy(sport.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="surface diagonal-cut tex-scan" style={{ padding: 24, display: 'grid', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<p className="mono lbl" style={{ color: 'var(--amber)' }}>OUTLOOK</p>
|
||||||
|
<h3 style={{ fontSize: 18, fontWeight: 700, marginTop: 4 }}>{title}</h3>
|
||||||
|
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mono lbl" style={{ color: 'var(--grade-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
|
||||||
|
{receipts.map((r: OutlookReceiptView, i: number) => (
|
||||||
|
<div key={`${r.player}-${r.stat}-${i}`} className="surface" style={{ minWidth: 190, padding: 14, border: '1px solid var(--grade-a)', borderRadius: 12 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>{String(r.sport).toUpperCase()}</span>
|
||||||
|
<GradeBadge grade={r.grade} size={28} glow />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.player}</div>
|
||||||
|
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="mono" style={{ fontSize: 12, fontWeight: 800, color: 'var(--grade-a)', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mono lbl" style={{ color: 'var(--text-tertiary)', marginBottom: 10 }}>TOMORROW · SCHEDULE — lines post on the day</div>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{preview.map((g: OutlookGameView) => (
|
||||||
|
<div key={g.id} className="mono" style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 13, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>{g.away} @ {g.home}</span>
|
||||||
|
{g.time && <span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{formatTime(g.time)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render-only views of the buildOutlook output (the JS adapter has no TS types).
|
||||||
|
interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null }
|
||||||
|
interface OutlookGameView { id: string; away: string; home: string; time: string | null }
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||||
@@ -306,6 +401,15 @@ export default function DashboardPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Wave 3 (Addition 2) — the model's record BY GRADE TIER. Self-fetches
|
||||||
|
/api/ledger/model (scoped to the selected sport) and fully self-hides
|
||||||
|
until a tier has a settled read — no empty header. The SAME shared
|
||||||
|
TierRecord that renders on /u and the ledger; the calibration
|
||||||
|
gradient is the proof higher grades win more. */}
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<TierRecord sport={sport} title="MODEL RECORD BY GRADE" dense />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
|
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
|
||||||
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
||||||
row isn't identical-weight noise; when tonight is empty we fall back to
|
row isn't identical-weight noise; when tonight is empty we fall back to
|
||||||
@@ -422,21 +526,9 @@ export default function DashboardPage() {
|
|||||||
{games === null ? (
|
{games === null ? (
|
||||||
<SkeletonRow stacked />
|
<SkeletonRow stacked />
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
|
// Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
|
||||||
// an off-season sport says when it returns; in-season = off-day.
|
// Yesterday's proven receipts + tomorrow's real schedule fill the grid.
|
||||||
<div
|
<DashboardOutlook sport={sport} />
|
||||||
className="surface diagonal-cut tex-scan"
|
|
||||||
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
|
|
||||||
>
|
|
||||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
|
|
||||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
|
|
||||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
|
|
||||||
{emptyStateCopy(sport.toLowerCase()).body}
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
|
||||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{games.map((g, idx) => (
|
{games.map((g, idx) => (
|
||||||
|
|||||||
@@ -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. */
|
/* Terminal's multi-column grid stacks on mobile. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.terminal-grid { grid-template-columns: 1fr !important; }
|
.terminal-grid { grid-template-columns: 1fr !important; }
|
||||||
|
/* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal
|
||||||
|
table keeps its own internal grid. */
|
||||||
|
.parsenal-grid { grid-template-columns: 1fr !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
|
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
|
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
|
||||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,30 +178,6 @@ export default function LedgerPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
|
|
||||||
|
|
||||||
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
|
|
||||||
* the proof the grades mean something. A tier under n≥20 shows "building",
|
|
||||||
* never a small-sample percentage. Self-hides until ANY tier is ready. */
|
|
||||||
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
|
||||||
const tiers = agg.by_tier || {};
|
|
||||||
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
|
|
||||||
if (!anyReady) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
|
||||||
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
|
|
||||||
const b = tiers[t];
|
|
||||||
const ready = b.hit_pct != null;
|
|
||||||
return (
|
|
||||||
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
|
|
||||||
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
|
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
|
||||||
* in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
|
* in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
|
||||||
* distribution renders as one giant bar + six empty stubs — it reads as
|
* distribution renders as one giant bar + six empty stubs — it reads as
|
||||||
@@ -305,7 +282,20 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ClvDistribution agg={agg} />
|
<ClvDistribution agg={agg} />
|
||||||
<TierCalibration agg={agg} minSample={minSample} />
|
{/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
|
||||||
|
identical here, on the dashboard, and on /u. */}
|
||||||
|
<TierRecord byTier={agg.by_tier} minSample={minSample} />
|
||||||
|
{/* Wave 5A (D2) — the shareable house/model profile. Same record, a
|
||||||
|
public page you can hand a partner. */}
|
||||||
|
<div style={{ marginTop: 14, textAlign: 'right' }}>
|
||||||
|
<Link
|
||||||
|
href="/u/vyndr"
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
VIEW AS PUBLIC PAGE →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -365,7 +355,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
|||||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||||||
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
|
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
|
||||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
@@ -85,8 +86,16 @@ export default function Home() {
|
|||||||
{/* Session 58 (work-order 1.4) — the public model record (proof-strip
|
{/* Session 58 (work-order 1.4) — the public model record (proof-strip
|
||||||
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
|
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
|
||||||
the real hit% + beat-close%. Self-hides with no data. */}
|
the real hit% + beat-close%. Self-hides with no data. */}
|
||||||
<div style={{ padding: '4px 16px 12px' }}>
|
<div style={{ padding: '4px 16px 12px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||||
<ModelRecord />
|
<ModelRecord />
|
||||||
|
{/* Wave 5A (D2) — the shareable public model record (the house profile). */}
|
||||||
|
<Link
|
||||||
|
href="/u/vyndr"
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-tertiary)', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
VIEW PUBLIC RECORD →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Founder-seat scarcity meter (§12) */}
|
{/* Founder-seat scarcity meter (§12) */}
|
||||||
<div style={{ padding: '0 16px 8px' }}>
|
<div style={{ padding: '0 16px 8px' }}>
|
||||||
|
|||||||
@@ -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 ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||||
|
import PitcherArsenal from '@/components/vyndr/PitcherArsenal';
|
||||||
import { archetypeInfo } from '@/lib/archetypes';
|
import { archetypeInfo } from '@/lib/archetypes';
|
||||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||||
|
|
||||||
@@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* C2. PITCHER IDENTITY (Wave 5B) — Baseball Savant arsenal lens. MLB only;
|
||||||
|
the component (heading included) SELF-HIDES for non-pitchers / when
|
||||||
|
Savant has no arsenal. Context, not a graded market value. */}
|
||||||
|
{p.sport === 'mlb' && (
|
||||||
|
<PitcherArsenal name={p.player} sport={p.sport} heading pitcher={{ name: p.player, team: p.team }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* D. ACTIVE PROPS */}
|
{/* D. ACTIVE PROPS */}
|
||||||
{p.activeProps?.length > 0 && (
|
{p.activeProps?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function DeskShowcase() {
|
|||||||
THE DESK · FLAGSHIP
|
THE DESK · FLAGSHIP
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
|
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
|
||||||
A $1M terminal.{' '}
|
Every grade, every alt line, live.{' '}
|
||||||
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
|
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
|
||||||
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wave 4A (Step 4) — this claim is now BACKED by a real feature: the
|
||||||
|
CONSENSUS vs MODEL strip (components/vyndr/MarketBreadth, fed by
|
||||||
|
lib/marketBreadth.collectBreadth) ships on the live slate/dashboard,
|
||||||
|
computing the median book line vs the model's projection. "live
|
||||||
|
line moves" is the existing snapshot line-deltas / LineSparkline.
|
||||||
|
No longer an empty promise — do not remove without removing those. */}
|
||||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
||||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const metadata: Metadata = {
|
|||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: '100vh' }}>
|
<main style={{ minHeight: '100vh' }}>
|
||||||
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the
|
{/* DS5 (#8) — Desk is the hero. The value-showing story leads, above the
|
||||||
grid, so the premium tier stops being an afterthought. */}
|
grid, so the premium tier stops being an afterthought. */}
|
||||||
<DeskShowcase />
|
<DeskShowcase />
|
||||||
<Pricing />
|
<Pricing />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { currentAccessToken } from '@/lib/authToken';
|
import { currentAccessToken } from '@/lib/authToken';
|
||||||
|
import { classifyRenewal } from '@/lib/billingDisplay';
|
||||||
|
|
||||||
interface FullProfile {
|
interface FullProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -125,7 +126,7 @@ export default function ProfilePage() {
|
|||||||
{tier !== 'free' && (
|
{tier !== 'free' && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
||||||
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
<Stat label="Renews" value={renewalLabel(profile.subscription_end)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export default function ProfilePage() {
|
|||||||
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
||||||
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
||||||
Cancellation scheduled. Access ends{' '}
|
Cancellation scheduled. Access ends{' '}
|
||||||
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
{accessEndsLabel(profile.subscription_end)}.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renewal render is guarded (Wave 1 trust bug) — VYNDR tiers are monthly, so a
|
||||||
|
// `subscription_end` far in the future is a comped/seed value, not a renewal.
|
||||||
|
// classifyRenewal decides; we never print a raw date the cadence can't justify.
|
||||||
|
function renewalLabel(subscriptionEnd: string | null): string {
|
||||||
|
const r = classifyRenewal(subscriptionEnd);
|
||||||
|
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
|
||||||
|
if (r.kind === 'none') return 'No scheduled renewal';
|
||||||
|
if (r.kind === 'lapsed') return 'Lapsed';
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cancel-scheduled line only shows a real near date; anything else falls to
|
||||||
|
// the honest "at period end" (never a fabricated 2036 access-end).
|
||||||
|
function accessEndsLabel(subscriptionEnd: string | null): string {
|
||||||
|
const r = classifyRenewal(subscriptionEnd);
|
||||||
|
if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString();
|
||||||
|
return 'at period end';
|
||||||
|
}
|
||||||
|
|
||||||
function tierColor(tier: string): string {
|
function tierColor(tier: string): string {
|
||||||
if (tier === 'desk') return 'var(--grade-a)';
|
if (tier === 'desk') return 'var(--grade-a)';
|
||||||
if (tier === 'analyst') return 'var(--grade-b)';
|
if (tier === 'analyst') return 'var(--grade-b)';
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
trackScanLimitHit,
|
trackScanLimitHit,
|
||||||
trackUpgradeClicked,
|
trackUpgradeClicked,
|
||||||
} from '@/lib/analytics';
|
} from '@/lib/analytics';
|
||||||
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||||
|
|
||||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||||
@@ -119,6 +120,9 @@ export default function ScanPage() {
|
|||||||
const [playerQuery, setPlayerQuery] = useState('');
|
const [playerQuery, setPlayerQuery] = useState('');
|
||||||
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
||||||
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
|
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
|
||||||
|
// Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric).
|
||||||
|
// Feeds the grade card's real headshot. null → team-colored monogram.
|
||||||
|
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||||
const [stat, setStat] = useState<string>('points');
|
const [stat, setStat] = useState<string>('points');
|
||||||
const [line, setLine] = useState<string>('');
|
const [line, setLine] = useState<string>('');
|
||||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||||
@@ -306,6 +310,7 @@ export default function ScanPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setPlayerQuery('');
|
setPlayerQuery('');
|
||||||
setSelectedPlayer('');
|
setSelectedPlayer('');
|
||||||
|
setSelectedPlayerId(null);
|
||||||
setLine('');
|
setLine('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,7 +447,6 @@ export default function ScanPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tonightsPlayers.map((p) => {
|
{tonightsPlayers.map((p) => {
|
||||||
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
|
|
||||||
const selected = selectedPlayer === p.name;
|
const selected = selectedPlayer === p.name;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -450,6 +454,7 @@ export default function ScanPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPlayer(p.name);
|
setSelectedPlayer(p.name);
|
||||||
|
setSelectedPlayerId(null); // tonight chips carry no id → monogram
|
||||||
setPlayerQuery(p.name);
|
setPlayerQuery(p.name);
|
||||||
setPlayerSuggestions([]);
|
setPlayerSuggestions([]);
|
||||||
// If the player has exactly one stat type with
|
// If the player has exactly one stat type with
|
||||||
@@ -475,22 +480,9 @@ export default function ScanPage() {
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* Wave 2A — tonight's players carry no id → team-colored
|
||||||
<img
|
monogram (branded, never a gray silhouette). */}
|
||||||
src={headshot}
|
<PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
|
||||||
alt=""
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--bg-elevated)',
|
|
||||||
flexShrink: 0,
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -511,6 +503,7 @@ export default function ScanPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPlayerQuery(e.target.value);
|
setPlayerQuery(e.target.value);
|
||||||
setSelectedPlayer('');
|
setSelectedPlayer('');
|
||||||
|
setSelectedPlayerId(null);
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -551,33 +544,25 @@ export default function ScanPage() {
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedPlayer(p.full_name);
|
setSelectedPlayer(p.full_name);
|
||||||
|
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
|
||||||
setPlayerQuery(p.full_name);
|
setPlayerQuery(p.full_name);
|
||||||
setPlayerSuggestions([]);
|
setPlayerSuggestions([]);
|
||||||
}}
|
}}
|
||||||
style={suggestionStyle}
|
style={suggestionStyle}
|
||||||
>
|
>
|
||||||
{/* Session 19 — headshot in search suggestions. The
|
{/* Wave 2A — real headshot in search suggestions. MLB's
|
||||||
/api/players/search response doesn't (yet) include
|
/api/players/search carries the MLBAM id on p.id (numeric);
|
||||||
league IDs, so we hit ESPN-CDN fallback or
|
NBA/WNBA ids are synthetic → guarded out → team-colored
|
||||||
silhouette. onError swaps in the silhouette when
|
monogram. Never a gray silhouette. */}
|
||||||
the league hasn't published one. */}
|
<span style={{ marginRight: 10, display: 'inline-flex' }}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<PlayerAvatar
|
||||||
<img
|
name={p.full_name}
|
||||||
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })}
|
sport={sport.toLowerCase() as HeadshotSport}
|
||||||
alt=""
|
playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
|
||||||
width={28}
|
team={p.team}
|
||||||
height={28}
|
size={28}
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
/>
|
||||||
style={{
|
</span>
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--bg-elevated)',
|
|
||||||
flexShrink: 0,
|
|
||||||
marginRight: 10,
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
|
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
|
||||||
{p.team && (
|
{p.team && (
|
||||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||||
@@ -731,6 +716,7 @@ export default function ScanPage() {
|
|||||||
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
||||||
data={mapScanToGradeResult({
|
data={mapScanToGradeResult({
|
||||||
player: selectedPlayer,
|
player: selectedPlayer,
|
||||||
|
playerId: selectedPlayerId,
|
||||||
sport,
|
sport,
|
||||||
stat,
|
stat,
|
||||||
line: Number(line),
|
line: Number(line),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import BookWordmark from '@/components/vyndr/BookWordmark';
|
||||||
|
import TierRecord from '@/components/vyndr/TierRecord';
|
||||||
|
import { buildTierRows } from '@/lib/tierRecord';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||||||
@@ -36,6 +39,8 @@ interface ProfileRow {
|
|||||||
revised_from_grade?: string | null;
|
revised_from_grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null }
|
||||||
|
|
||||||
interface ProfileAggregate {
|
interface ProfileAggregate {
|
||||||
settled: number;
|
settled: number;
|
||||||
hits: number;
|
hits: number;
|
||||||
@@ -46,6 +51,9 @@ interface ProfileAggregate {
|
|||||||
clv_beat: number;
|
clv_beat: number;
|
||||||
beat_close_pct: number | null;
|
beat_close_pct: number | null;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
// Wave 3 (Addition 2) — per-tier calibration (survives the API + proxy
|
||||||
|
// untouched, inside the aggregate). The credibility centerpiece.
|
||||||
|
by_tier?: Record<string, TierBucket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfilePayload {
|
interface ProfilePayload {
|
||||||
@@ -53,6 +61,11 @@ interface ProfilePayload {
|
|||||||
aggregate: ProfileAggregate | null;
|
aggregate: ProfileAggregate | null;
|
||||||
entries: ProfileRow[];
|
entries: ProfileRow[];
|
||||||
min_sample?: number;
|
min_sample?: number;
|
||||||
|
// Wave 5A (D2) — the HOUSE/model profile: the public user_id=NULL record,
|
||||||
|
// shareable without a claimed public_profiles row. `label` distinguishes it
|
||||||
|
// from a user profile ("VYNDR MODEL · PUBLIC RECORD").
|
||||||
|
house?: boolean;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPORT_COLOR: Record<string, string> = {
|
const SPORT_COLOR: Record<string, string> = {
|
||||||
@@ -107,24 +120,35 @@ export default function PublicProfile({ handle }: { handle: string }) {
|
|||||||
|
|
||||||
const agg = data.aggregate;
|
const agg = data.aggregate;
|
||||||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||||||
|
const isHouse = Boolean(data.house);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||||
<header style={{ marginBottom: 20 }}>
|
<header style={{ marginBottom: 20 }}>
|
||||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
|
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
|
||||||
PUBLIC LEDGER
|
{isHouse ? (data.label || 'VYNDR MODEL · PUBLIC RECORD') : 'PUBLIC LEDGER'}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||||
@{data.handle}
|
{isHouse ? 'VYNDR MODEL' : `@${data.handle}`}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||||
Every settled read. Wins and misses. Nothing curated.
|
{isHouse
|
||||||
|
? 'The VYNDR model’s public record. Every graded read, wins and misses, closing-line value included.'
|
||||||
|
: 'Every settled read. Wins and misses. Nothing curated.'}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Record header — never a percentage under min_sample settles. */}
|
{/* Record header — never a percentage under min_sample settles. */}
|
||||||
<RecordHeader agg={agg} minSample={minSample} />
|
<RecordHeader agg={agg} minSample={minSample} />
|
||||||
|
|
||||||
|
{/* Wave 3 (Addition 2) — the record BY GRADE TIER, the proof that higher
|
||||||
|
grades win more. Same shared component as the dashboard + ledger. */}
|
||||||
|
{agg && buildTierRows(agg.by_tier || {}).length > 0 && (
|
||||||
|
<div className="surface diagonal-cut" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<TierRecord byTier={agg.by_tier} minSample={minSample} dense />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.entries.length === 0 ? (
|
{data.entries.length === 0 ? (
|
||||||
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
||||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||||
@@ -272,7 +296,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const contentType = 'image/png';
|
|||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> {
|
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number; house: boolean } | null> {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
@@ -21,7 +21,7 @@ async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; be
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const agg = d && d.aggregate;
|
const agg = d && d.aggregate;
|
||||||
if (!agg || agg.hit_pct == null) return null;
|
if (!agg || agg.hit_pct == null) return null;
|
||||||
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses };
|
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses, house: Boolean(d.house) };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -49,10 +49,10 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
|
|||||||
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
|
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
||||||
{rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
|
{rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
|
<div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
|
||||||
@{h}
|
{rec?.house ? 'VYNDR MODEL' : `@${h}`}
|
||||||
</div>
|
</div>
|
||||||
{rec ? (
|
{rec ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
|
||||||
|
|||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import type { HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
{visible.map((p) => (
|
{visible.map((p) => (
|
||||||
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
|
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
|
||||||
<span style={rankStyle}>#{p.rank}</span>
|
<span style={rankStyle}>#{p.rank}</span>
|
||||||
<img
|
{/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
|
||||||
src={getHeadshotUrl({ sport, playerId: p.playerId })}
|
team-colored monogram otherwise. Never a gray silhouette. */}
|
||||||
alt={p.name}
|
<PlayerAvatar
|
||||||
width={36}
|
name={p.name}
|
||||||
height={36}
|
sport={sport as HeadshotSport}
|
||||||
style={avatarStyle}
|
playerId={p.playerId}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
team={p.team}
|
||||||
|
size={36}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
|
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
|
||||||
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = {
|
|||||||
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
|
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
|
||||||
color: 'var(--text-tertiary, #6A6A78)',
|
color: 'var(--text-tertiary, #6A6A78)',
|
||||||
};
|
};
|
||||||
const avatarStyle: React.CSSProperties = {
|
|
||||||
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
|
|
||||||
};
|
|
||||||
const playerName: React.CSSProperties = {
|
const playerName: React.CSSProperties = {
|
||||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
||||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
|||||||
@@ -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,
|
highlight: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal"
|
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing
|
||||||
// story and the single primary CTA on the grid (color contract #9: never
|
// story and the single primary CTA on the grid (color contract #9: never
|
||||||
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
||||||
id: 'desk',
|
id: 'desk',
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
|
|||||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
|
||||||
|
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
|
||||||
|
// live games (and it's not a fetch failure) the grid shows REAL data —
|
||||||
|
// yesterday's proven receipts + tomorrow's date-pinned schedule.
|
||||||
|
import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook';
|
||||||
|
// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model.
|
||||||
|
import { collectBreadth } from '@/lib/marketBreadth';
|
||||||
|
import MarketBreadth from '@/components/vyndr/MarketBreadth';
|
||||||
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
|
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
|
||||||
// Grades never change in-game; these marks are tracking, labeled as such.
|
// Grades never change in-game; these marks are tracking, labeled as such.
|
||||||
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
||||||
@@ -165,7 +172,7 @@ interface StreakApiRow {
|
|||||||
interface StreaksResponse { streaks?: StreakApiRow[] }
|
interface StreaksResponse { streaks?: StreakApiRow[] }
|
||||||
|
|
||||||
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
|
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
|
||||||
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
|
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
|
||||||
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
||||||
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
||||||
|
|
||||||
@@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The /api/ledger/model settled-row shape (subset the receipts read).
|
||||||
|
interface ModelReceiptRow {
|
||||||
|
player_name?: string; player?: string; sport?: string; stat?: string;
|
||||||
|
line?: number; side?: string; grade?: string;
|
||||||
|
outcome?: string | null; actual_value?: number | null; clv_result?: string | null;
|
||||||
|
}
|
||||||
|
// buildHeroReceipts output (proven yesterday hit).
|
||||||
|
interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null }
|
||||||
|
// mapTomorrowPreview output.
|
||||||
|
interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in
|
||||||
|
* place of the old dead-end CTA: yesterday's PROVEN A-tier receipts +
|
||||||
|
* tomorrow's date-pinned schedule preview (both REAL, always-available data —
|
||||||
|
* never an invented line). The month-aware header ALWAYS shows, so the grid is
|
||||||
|
* never blank. Distinct from the network `fetchError` state.
|
||||||
|
*/
|
||||||
|
function OutlookSurface({ tab }: { tab: SlateTab }) {
|
||||||
|
const [settled, setSettled] = useState<ModelReceiptRow[] | null>(null);
|
||||||
|
const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : '';
|
||||||
|
fetch(`/api/ledger/model?limit=80${sportQ}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
|
||||||
|
.catch(() => { if (active) setSettled([]); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned
|
||||||
|
const SPORTS: SlateSport[] = tab === 'all'
|
||||||
|
? ['mlb', 'nba', 'wnba']
|
||||||
|
: (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : [];
|
||||||
|
if (SPORTS.length === 0) { setTomorrow([]); return; }
|
||||||
|
Promise.all(SPORTS.map(async (sport) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' });
|
||||||
|
if (!r.ok) return [] as ScheduleGame[];
|
||||||
|
const d = (await r.json()) as ScheduleResponse;
|
||||||
|
return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport }));
|
||||||
|
} catch { return [] as ScheduleGame[]; }
|
||||||
|
})).then((lists) => { if (active) setTomorrow(lists.flat()); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
const outlook = useMemo(
|
||||||
|
() => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] },
|
||||||
|
[settled, tomorrow],
|
||||||
|
);
|
||||||
|
const receipts: OutlookReceipt[] = outlook.receipts ?? [];
|
||||||
|
const preview: OutlookGame[] = outlook.tomorrow ?? [];
|
||||||
|
const { title, body } = emptyStateCopy(tab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
|
||||||
|
<div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
|
||||||
|
<p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
|
||||||
|
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
|
||||||
|
<p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<div style={{ marginBottom: preview.length ? 18 : 0 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
|
||||||
|
{receipts.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={`${r.player}-${r.stat}-${i}`}
|
||||||
|
className="mono"
|
||||||
|
style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
|
||||||
|
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
|
||||||
|
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
|
||||||
|
TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}>— lines post on the day</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 7 }}>
|
||||||
|
{preview.map((g) => (
|
||||||
|
<div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
|
||||||
|
{g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
|
||||||
|
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
|
||||||
|
{g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlateProps {
|
export interface SlateProps {
|
||||||
initialTab?: SlateTab;
|
initialTab?: SlateTab;
|
||||||
tier?: Tier;
|
tier?: Tier;
|
||||||
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
)) as SlateGame[];
|
)) as SlateGame[];
|
||||||
}, [filteredGames, gradeIndex, liveIndex]);
|
}, [filteredGames, gradeIndex, liveIndex]);
|
||||||
|
|
||||||
|
// Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that
|
||||||
|
// carries ≥2 book lines, compare the market's median line to the model's
|
||||||
|
// projection (signed by the graded side). collectBreadth drops <2-book props
|
||||||
|
// and ranks by |edge| — an empty result self-hides the strip. This is the
|
||||||
|
// REAL data behind the DeskShowcase "consensus vs model" claim.
|
||||||
|
const breadthItems = useMemo(() => {
|
||||||
|
const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = [];
|
||||||
|
for (const g of filteredGames) {
|
||||||
|
for (const p of g.props) {
|
||||||
|
if (!Array.isArray(p.books) || p.books.length < 2) continue;
|
||||||
|
const grade = (gradeIndex as Record<string, SnapshotGrade>)[gradeKey(p.player, p.stat_type)];
|
||||||
|
const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null;
|
||||||
|
items.push({
|
||||||
|
player: p.player,
|
||||||
|
stat: p.stat_type,
|
||||||
|
side: (grade && grade.direction) || p.direction || 'over',
|
||||||
|
line: p.line,
|
||||||
|
books: p.books,
|
||||||
|
modelValue: mv == null ? null : Number(mv),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collectBreadth(items, 6);
|
||||||
|
}, [filteredGames, gradeIndex]);
|
||||||
|
|
||||||
// Session 25 — per-sport game counts for the tab labels, derived from
|
// Session 25 — per-sport game counts for the tab labels, derived from
|
||||||
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
|
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
|
||||||
// free ESPN schedule even when odds are empty. Counts only appear for
|
// free ESPN schedule even when odds are empty. Counts only appear for
|
||||||
@@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !fetchError && filteredGames.length === 0 && (
|
{/* A search miss keeps its own scan-it CTA. */}
|
||||||
|
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
|
||||||
<div
|
<div
|
||||||
className="surface"
|
className="surface"
|
||||||
style={{
|
style={{
|
||||||
@@ -976,46 +1122,65 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
color: 'var(--text-secondary, #8A8A9A)',
|
color: 'var(--text-secondary, #8A8A9A)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{searchQuery ? (
|
<p style={{ marginBottom: 12 }}>
|
||||||
<>
|
No props found for “{searchQuery}”.
|
||||||
<p style={{ marginBottom: 12 }}>
|
</p>
|
||||||
No props found for “{searchQuery}”.
|
<a
|
||||||
</p>
|
href={manualScanHref}
|
||||||
<a
|
className="btn-primary"
|
||||||
href={manualScanHref}
|
style={{
|
||||||
className="btn-primary"
|
display: 'inline-block',
|
||||||
style={{
|
padding: '8px 16px',
|
||||||
display: 'inline-block',
|
background: 'var(--grade-a, #00D4A0)',
|
||||||
padding: '8px 16px',
|
color: 'var(--bg-0, #0A0A0F)',
|
||||||
background: 'var(--grade-a, #00D4A0)',
|
borderRadius: 4,
|
||||||
color: 'var(--bg-0, #0A0A0F)',
|
textDecoration: 'none',
|
||||||
borderRadius: 4,
|
fontSize: 13,
|
||||||
textDecoration: 'none',
|
fontWeight: 700,
|
||||||
fontSize: 13,
|
}}
|
||||||
fontWeight: 700,
|
>
|
||||||
}}
|
Scan it manually →
|
||||||
>
|
</a>
|
||||||
Scan it manually →
|
</div>
|
||||||
</a>
|
)}
|
||||||
</>
|
|
||||||
) : (
|
{/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
|
||||||
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
|
when there are no live games (and no search, no fetch failure) it shows
|
||||||
// off-season sports name their return window; in-season = off-day.
|
yesterday's proven receipts + tomorrow's real schedule. Yesterday/
|
||||||
(() => {
|
Tomorrow date nav keep the plain honest copy (those are explicit date
|
||||||
const { title, body } = emptyStateCopy(tab);
|
surfaces; -1 already has THE SETTLE below). */}
|
||||||
return (
|
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
|
||||||
<>
|
<OutlookSurface tab={tab} />
|
||||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
)}
|
||||||
<p>{body}</p>
|
{!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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
|
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
|
||||||
|
|
||||||
|
{/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
|
||||||
|
graded prop has a real ≥2-book median to compare the model against. */}
|
||||||
|
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 16 }}>
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
{orderedGames.map((g, i) => (
|
{orderedGames.map((g, i) => (
|
||||||
<VyndrGameCard
|
<VyndrGameCard
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ interface ArchetypeBadgeProps {
|
|||||||
*/
|
*/
|
||||||
export default function ArchetypeBadge({
|
export default function ArchetypeBadge({
|
||||||
archetype,
|
archetype,
|
||||||
|
sport,
|
||||||
variant = 'tint',
|
variant = 'tint',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
showDesc = false,
|
showDesc = false,
|
||||||
}: ArchetypeBadgeProps) {
|
}: ArchetypeBadgeProps) {
|
||||||
const s = badgeStyle(archetype, variant, size);
|
const s = badgeStyle(archetype, variant, size, sport);
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||||
<span
|
<span
|
||||||
@@ -46,10 +47,20 @@ export default function ArchetypeBadge({
|
|||||||
textShadow: s.textShadow,
|
textShadow: s.textShadow,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{s.glyphChar ? (
|
||||||
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
// Combat glyphs are unicode chars (data never glitches — chrome label).
|
||||||
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
<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}
|
{s.name}
|
||||||
</span>
|
</span>
|
||||||
{showDesc && s.desc && (
|
{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
|
* BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a
|
||||||
* the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
|
* bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8
|
||||||
* lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts
|
* major books) it renders that; otherwise it falls back to the real book NAME
|
||||||
* where the BookChip tile is too heavy. `best` gives it the signal ring.
|
* in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
|
||||||
|
* lowercase "draftkings" string (DESIGN-SPEC Part 2).
|
||||||
|
*
|
||||||
|
* The bundled SVGs are self-authored styled text wordmarks — not copied
|
||||||
|
* trademarked logo glyphs — so the founder can drop official press-kit art into
|
||||||
|
* the same paths later with zero code change. The files are local + always
|
||||||
|
* present, so no broken-image state is possible. `best` gives the signal ring.
|
||||||
*/
|
*/
|
||||||
export default function BookWordmark({
|
export default function BookWordmark({
|
||||||
book,
|
book,
|
||||||
@@ -16,6 +22,8 @@ export default function BookWordmark({
|
|||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const b = bookInfo(book);
|
const b = bookInfo(book);
|
||||||
|
const slug = bookSlug(book);
|
||||||
|
const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="mono"
|
className="mono"
|
||||||
@@ -29,7 +37,12 @@ export default function BookWordmark({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
|
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
|
||||||
{b.name}
|
{svg ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={svg} alt={b.name} height={Math.round(size * 1.35)} style={{ display: 'block', width: 'auto', height: Math.round(size * 1.35) }} />
|
||||||
|
) : (
|
||||||
|
b.name
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 TeamLogo from '@/components/vyndr/TeamLogo';
|
||||||
import { accentColor } from '@/lib/teamMeta';
|
import { accentColor } from '@/lib/teamMeta';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { isPreferredBook } from '@/lib/books';
|
import { isPreferredBook, bookInfo } from '@/lib/books';
|
||||||
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
||||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||||
@@ -37,6 +37,9 @@ export interface GameProp {
|
|||||||
export interface PlayerStrip {
|
export interface PlayerStrip {
|
||||||
player: string;
|
player: string;
|
||||||
team: string;
|
team: string;
|
||||||
|
// Wave 2A — real headshot ids threaded from the snapshot grade.
|
||||||
|
playerId?: string | number | null;
|
||||||
|
espnId?: string | number | null;
|
||||||
archetype?: StripArchetype;
|
archetype?: StripArchetype;
|
||||||
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
|
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
|
||||||
lineup?: { status: string; slot?: number } | null;
|
lineup?: { status: string; slot?: number } | null;
|
||||||
@@ -301,7 +304,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
|||||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||||
{g.lines.map((ln, i) => (
|
{g.lines.map((ln, i) => (
|
||||||
<span key={i} style={{ display: 'contents' }}>
|
<span key={i} style={{ display: 'contents' }}>
|
||||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{ln.book}</div>
|
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{bookInfo(ln.book).name}</div>
|
||||||
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
||||||
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
||||||
<LineCell value={ln.ou} best={ln.bestOU} />
|
<LineCell value={ln.ou} best={ln.bestOU} />
|
||||||
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
|||||||
player={ps.player}
|
player={ps.player}
|
||||||
team={ps.team}
|
team={ps.team}
|
||||||
sport={g.sport}
|
sport={g.sport}
|
||||||
|
playerId={ps.playerId}
|
||||||
|
espnId={ps.espnId}
|
||||||
archetype={ps.archetype}
|
archetype={ps.archetype}
|
||||||
lineup={ps.lineup}
|
lineup={ps.lineup}
|
||||||
injury={ps.injury}
|
injury={ps.injury}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import SectionHead from '@/components/vyndr/SectionHead';
|
|||||||
import VBtn from '@/components/vyndr/VBtn';
|
import VBtn from '@/components/vyndr/VBtn';
|
||||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import GradeShift from '@/components/vyndr/GradeShift';
|
||||||
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||||||
import { edgeColor, gradeGlows } from '@/lib/colorContract';
|
import { edgeColor, gradeGlows } from '@/lib/colorContract';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
@@ -14,6 +17,10 @@ export interface GradeResultData {
|
|||||||
player: string;
|
player: string;
|
||||||
team: string;
|
team: string;
|
||||||
sport: string;
|
sport: string;
|
||||||
|
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
|
||||||
|
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
|
||||||
|
playerId?: string | number | null;
|
||||||
|
espnId?: string | number | null;
|
||||||
stat: string;
|
stat: string;
|
||||||
line: number;
|
line: number;
|
||||||
side: 'Over' | 'Under';
|
side: 'Over' | 'Under';
|
||||||
@@ -33,6 +40,12 @@ export interface GradeResultData {
|
|||||||
propDNA?: { reliable: string[]; volatile: string[] };
|
propDNA?: { reliable: string[]; volatile: string[] };
|
||||||
statContext?: { season?: string; last10?: string; vsOpp?: string };
|
statContext?: { season?: string; last10?: string; vsOpp?: string };
|
||||||
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
|
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
|
||||||
|
// Wave 4B — LIVE GRADE-SHIFT timeline (all optional; GradeShift self-hides
|
||||||
|
// below 3 real captured points). Fed by the snapshot pipeline's already-
|
||||||
|
// emitted line history + public revision; the scan path leaves them absent.
|
||||||
|
history?: Array<{ t: string; line: number }> | null;
|
||||||
|
revisedFrom?: string | null;
|
||||||
|
gradedLine?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GradeResultCardProps {
|
interface GradeResultCardProps {
|
||||||
@@ -96,12 +109,17 @@ export default function GradeResultCard({
|
|||||||
|
|
||||||
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||||||
|
{/* Wave 2A — player identity block: real headshot / team-colored
|
||||||
|
monogram (never a gray silhouette). */}
|
||||||
|
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
||||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||||
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
||||||
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||||
<SportBadge sport={d.sport} />
|
<SportBadge sport={d.sport} />
|
||||||
@@ -187,6 +205,12 @@ export default function GradeResultCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 4b. LIVE GRADE-SHIFT (Wave 4B) — the line/grade movement timeline over
|
||||||
|
the snapshot pipeline's already-emitted history. Self-hides below 3
|
||||||
|
real captured points, so the scan path (no captured history) shows
|
||||||
|
nothing rather than a fabricated timeline. */}
|
||||||
|
<GradeShift history={d.history} side={d.side} grade={d.grade} revisedFrom={d.revisedFrom} gradedLine={d.gradedLine ?? d.line} />
|
||||||
|
|
||||||
{/* 5. SIGNAL BREAKDOWN */}
|
{/* 5. SIGNAL BREAKDOWN */}
|
||||||
{d.signals.length > 0 && (
|
{d.signals.length > 0 && (
|
||||||
<div style={{ padding: '16px 20px' }}>
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
|||||||
@@ -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,
|
name,
|
||||||
sport = 'mlb',
|
sport = 'mlb',
|
||||||
playerId,
|
playerId,
|
||||||
|
espnId,
|
||||||
team,
|
team,
|
||||||
size = 36,
|
size = 36,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
sport?: HeadshotSport;
|
sport?: HeadshotSport;
|
||||||
|
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
|
||||||
playerId?: string | number | null;
|
playerId?: string | number | null;
|
||||||
|
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||||
|
espnId?: string | number | null;
|
||||||
team?: string | null;
|
team?: string | null;
|
||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const [broken, setBroken] = useState(false);
|
const [broken, setBroken] = useState(false);
|
||||||
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null;
|
// Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
|
||||||
|
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
|
||||||
|
// all → null → team-colored monogram (never a gray silhouette).
|
||||||
|
const url = (playerId != null || espnId != null)
|
||||||
|
? getHeadshotUrl({ sport, playerId, espnId })
|
||||||
|
: null;
|
||||||
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
|
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
|
||||||
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
|
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { searchTeams, teamHref } from '@/lib/teams';
|
import { searchTeams, teamHref } from '@/lib/teams';
|
||||||
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchModal — S6 (A1 board). Global search over players + teams.
|
* SearchModal — S6 (A1 board). Global search over players + teams.
|
||||||
@@ -31,6 +33,8 @@ interface ResultItem {
|
|||||||
sub: string; // team / abbr context
|
sub: string; // team / abbr context
|
||||||
sport: string;
|
sport: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
// Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic).
|
||||||
|
playerId?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: ResultItem[] = [];
|
const merged: ResultItem[] = [];
|
||||||
for (const res of responses) {
|
for (const res of responses) {
|
||||||
for (const p of res.players as Array<{ full_name?: string; team?: string }>) {
|
for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) {
|
||||||
const name = String(p.full_name || '').trim();
|
const name = String(p.full_name || '').trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const key = `${name.toLowerCase()}|${res.sport}`;
|
const key = `${name.toLowerCase()}|${res.sport}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
// MLB's search id is the real MLBAM id → headshot. Other sports'
|
||||||
|
// ids are synthetic → guarded out → monogram.
|
||||||
|
const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null;
|
||||||
merged.push({
|
merged.push({
|
||||||
kind: 'player',
|
kind: 'player',
|
||||||
label: name,
|
label: name,
|
||||||
sub: p.team ? String(p.team) : '',
|
sub: p.team ? String(p.team) : '',
|
||||||
sport: res.sport,
|
sport: res.sport,
|
||||||
href: playerHref(name, res.sport),
|
href: playerHref(name, res.sport),
|
||||||
|
playerId,
|
||||||
});
|
});
|
||||||
if (merged.length >= 9) break;
|
if (merged.length >= 9) break;
|
||||||
}
|
}
|
||||||
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
|
{/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds
|
||||||
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
|
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 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
|
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
|
||||||
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
|
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ interface StatStripProps {
|
|||||||
player: string;
|
player: string;
|
||||||
team: string;
|
team: string;
|
||||||
sport?: string;
|
sport?: string;
|
||||||
|
// Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM
|
||||||
|
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
|
||||||
|
playerId?: string | number | null;
|
||||||
|
espnId?: string | number | null;
|
||||||
archetype?: StripArchetype;
|
archetype?: StripArchetype;
|
||||||
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
|
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
|
||||||
lineup?: { status: string; slot?: number } | null;
|
lineup?: { status: string; slot?: number } | null;
|
||||||
@@ -225,6 +229,8 @@ export default function StatStrip({
|
|||||||
player,
|
player,
|
||||||
team,
|
team,
|
||||||
sport,
|
sport,
|
||||||
|
playerId,
|
||||||
|
espnId,
|
||||||
archetype,
|
archetype,
|
||||||
lineup,
|
lineup,
|
||||||
injury,
|
injury,
|
||||||
@@ -378,7 +384,7 @@ export default function StatStrip({
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||||
{/* DS0 — player identity block: real headshot / team-colored monogram
|
{/* DS0 — player identity block: real headshot / team-colored monogram
|
||||||
(never the gray silhouette). Team drives the accent color. */}
|
(never the gray silhouette). Team drives the accent color. */}
|
||||||
<PlayerAvatar name={player} sport={sport} team={team} size={30} />
|
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
|
||||||
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
|
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
|
||||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
||||||
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
||||||
|
|||||||
@@ -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 Sparkline } from './Sparkline';
|
||||||
export { default as Ticker } from './Ticker';
|
export { default as Ticker } from './Ticker';
|
||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
|
export { default as MarketBreadth } from './MarketBreadth';
|
||||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||||
export { default as GradeResultCard } from './GradeResultCard';
|
export { default as GradeResultCard } from './GradeResultCard';
|
||||||
export type { GradeResultData } from './GradeResultCard';
|
export type { GradeResultData } from './GradeResultCard';
|
||||||
@@ -18,13 +19,18 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f
|
|||||||
export { default as ClaimMeter } from './ClaimMeter';
|
export { default as ClaimMeter } from './ClaimMeter';
|
||||||
export { default as AccuracyBadge } from './AccuracyBadge';
|
export { default as AccuracyBadge } from './AccuracyBadge';
|
||||||
export { default as ModelRecord } from './ModelRecord';
|
export { default as ModelRecord } from './ModelRecord';
|
||||||
|
export { default as TierRecord } from './TierRecord';
|
||||||
|
|
||||||
/* Player Intelligence (Session 42) */
|
/* Player Intelligence (Session 42) */
|
||||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||||
|
export { default as FightCard } from './FightCard';
|
||||||
|
export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard';
|
||||||
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||||
export type { BlendSegment } from './ArchetypeBlend';
|
export type { BlendSegment } from './ArchetypeBlend';
|
||||||
export { default as StatStrip } from './StatStrip';
|
export { default as StatStrip } from './StatStrip';
|
||||||
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
|
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
|
||||||
|
export { default as PitcherArsenal } from './PitcherArsenal';
|
||||||
|
export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal';
|
||||||
export { default as BookChip } from './BookChip';
|
export { default as BookChip } from './BookChip';
|
||||||
|
|
||||||
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
|
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export const SPORTS: Record<SportKey, SportConfig> = {
|
|||||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
// Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
|
||||||
|
// tale-of-the-tape). Not in the graded-props pipeline yet → collectData false.
|
||||||
|
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
|
||||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ const ARCHETYPE_MAP = {
|
|||||||
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
|
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Combat archetype visual map (Wave 6 — MMA/UFC). SEPARATE from ARCHETYPE_MAP:
|
||||||
|
FINISHER's combat name/color/glyph differ from the soccer FINISHER, and
|
||||||
|
combat FINISHER's green is intentionally close to the pitch-green here. Keys/
|
||||||
|
colors/glyph CHARS MUST match src/services/archetypeService.js
|
||||||
|
COMBAT_ARCHETYPES — tests/unit/combatArchetypes.test.js asserts it.
|
||||||
|
`char` is a unicode glyph (rendered as text, not an SVG glyph key). */
|
||||||
|
const COMBAT_ARCHETYPE_MAP = {
|
||||||
|
STRIKER: { c: '#E8703A', d: 'Wins on the feet — volume + power at range.', char: '✦', axis: 'range' },
|
||||||
|
GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms — control + subs.', char: '⊗', axis: 'range' },
|
||||||
|
PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➤', axis: 'tempo' },
|
||||||
|
COUNTER: { c: '#8E7BE0', d: 'Patient — punishes what you show him.', char: '◊', axis: 'tempo' },
|
||||||
|
FINISHER: { c: '#12B886', d: 'Ends nights — high KO/SUB rate.', char: '▲', axis: 'outcome' },
|
||||||
|
GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: '▦', axis: 'outcome' },
|
||||||
|
};
|
||||||
|
|
||||||
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
||||||
|
|
||||||
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
|
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
|
||||||
@@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
|
|||||||
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
|
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
|
||||||
}
|
}
|
||||||
|
|
||||||
function archetypeInfo(name) {
|
function archetypeInfo(name, sport) {
|
||||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||||
|
// Combat archetypes live in their own namespace (FINISHER collides with the
|
||||||
|
// soccer archetype) — resolve them ONLY when the sport is MMA.
|
||||||
|
if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) {
|
||||||
|
return COMBAT_ARCHETYPE_MAP[key];
|
||||||
|
}
|
||||||
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
|
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
|
||||||
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
|
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
function archetypeColor(name) {
|
function archetypeColor(name, sport) {
|
||||||
return archetypeInfo(name).c;
|
return archetypeInfo(name, sport).c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combat-only color lookup (unambiguous — no soccer FINISHER collision). */
|
||||||
|
function combatArchetypeColor(name) {
|
||||||
|
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||||
|
return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c;
|
||||||
}
|
}
|
||||||
|
|
||||||
function glyphSvg(glyphKey) {
|
function glyphSvg(glyphKey) {
|
||||||
@@ -114,8 +140,8 @@ function glyphSvg(glyphKey) {
|
|||||||
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||||
* size: 'sm' | 'md'.
|
* size: 'sm' | 'md'.
|
||||||
*/
|
*/
|
||||||
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
function badgeStyle(name, variant = 'tint', size = 'sm', sport) {
|
||||||
const info = archetypeInfo(name);
|
const info = archetypeInfo(name, sport);
|
||||||
const sm = size === 'sm';
|
const sm = size === 'sm';
|
||||||
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||||
if (variant === 'full' || variant === 'solid') {
|
if (variant === 'full' || variant === 'solid') {
|
||||||
@@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
|||||||
}
|
}
|
||||||
// Display the canonical VYNDR name even if a legacy name was passed.
|
// Display the canonical VYNDR name even if a legacy name was passed.
|
||||||
const upper = (name == null ? '' : String(name)).toUpperCase();
|
const upper = (name == null ? '' : String(name)).toUpperCase();
|
||||||
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper];
|
||||||
|
const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
||||||
return {
|
return {
|
||||||
name: canonical,
|
name: canonical,
|
||||||
desc: info.d,
|
desc: info.d,
|
||||||
glyph: info.g,
|
glyph: info.g,
|
||||||
|
// Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys).
|
||||||
|
glyphChar: isCombat ? info.char : null,
|
||||||
color: info.c,
|
color: info.c,
|
||||||
textColor, bg, borderColor, glyphColor, textShadow,
|
textColor, bg, borderColor, glyphColor, textShadow,
|
||||||
fontSize: sm ? '9.5px' : '12px',
|
fontSize: sm ? '9.5px' : '12px',
|
||||||
@@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
GLYPHS,
|
GLYPHS,
|
||||||
ARCHETYPE_MAP,
|
ARCHETYPE_MAP,
|
||||||
|
COMBAT_ARCHETYPE_MAP,
|
||||||
archetypeInfo,
|
archetypeInfo,
|
||||||
archetypeColor,
|
archetypeColor,
|
||||||
|
combatArchetypeColor,
|
||||||
glyphSvg,
|
glyphSvg,
|
||||||
badgeStyle,
|
badgeStyle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
|
||||||
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
|
BOOKS table. CommonJS so it's testable + importable from the .tsx chip.
|
||||||
|
|
||||||
|
Wave 2B (data train) — the map now covers EVERY key the odds feed emits
|
||||||
|
(`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars,
|
||||||
|
fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book
|
||||||
|
falls to the neutral-gray default. Entries carry a `slug` = the canonical
|
||||||
|
lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and
|
||||||
|
BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under
|
||||||
|
`web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for
|
||||||
|
official press-kit art without a code change). */
|
||||||
|
|
||||||
const BOOKS = {
|
const BOOKS = {
|
||||||
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
|
// BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
|
||||||
|
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
|
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||||
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
|
FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||||
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
|
||||||
|
BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
|
||||||
|
HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||||
|
HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||||
|
POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' },
|
||||||
|
PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' },
|
||||||
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg).
|
||||||
|
// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs.
|
||||||
|
const BUNDLED_BOOK_SVGS = new Set([
|
||||||
|
'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_FG = '#B8BCC8';
|
||||||
|
|
||||||
function bookInfo(book) {
|
function bookInfo(book) {
|
||||||
const key = String(book == null ? '' : book).toUpperCase();
|
const key = String(book == null ? '' : book).toUpperCase();
|
||||||
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
|
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical lowercase book slug for any input (id/code/name), or null when the
|
||||||
|
* book isn't in the registry. Used to look up the bundled wordmark SVG. */
|
||||||
|
function bookSlug(book) {
|
||||||
|
const info = bookInfo(book);
|
||||||
|
if (info.slug) return info.slug;
|
||||||
|
// Unknown-but-nameable: derive a slug from the resolved name (still not the
|
||||||
|
// raw lowercase feed key). Only bundled slugs matter for the SVG lookup.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does `book` have a bundled local wordmark SVG? */
|
||||||
|
function hasBookSvg(book) {
|
||||||
|
const slug = bookSlug(book);
|
||||||
|
return slug != null && BUNDLED_BOOK_SVGS.has(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
|
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
|
||||||
@@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) {
|
|||||||
return preferred.some((p) => bookKey(p) === k);
|
return preferred.some((p) => bookKey(p) === k);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook };
|
module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook };
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) {
|
|||||||
player: input.player || '',
|
player: input.player || '',
|
||||||
team: input.team || '',
|
team: input.team || '',
|
||||||
sport: (input.sport || 'nba').toString().toLowerCase(),
|
sport: (input.sport || 'nba').toString().toLowerCase(),
|
||||||
|
// Wave 2A — real headshot ids (optional; card self-hides to a monogram).
|
||||||
|
playerId: input.playerId != null ? input.playerId : null,
|
||||||
|
espnId: input.espnId != null ? input.espnId : null,
|
||||||
stat: statLabel(input.stat),
|
stat: statLabel(input.stat),
|
||||||
line,
|
line,
|
||||||
side,
|
side,
|
||||||
|
|||||||
@@ -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
|
* The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
|
||||||
* primary because (a) ESPN rate-limits image hotlinking and (b) the
|
* requireable by the plain-JS Jest suite AND importable here (allowJs) — same
|
||||||
* league CDNs are the same sources PrizePicks and Sleeper use, so
|
* single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
|
||||||
* coverage is closer to 100%.
|
* file adds the TypeScript surface (types + the `headshotFromPlayer` helper).
|
||||||
|
*
|
||||||
|
* Each league hosts its own CDN; we don't proxy through ESPN as the primary
|
||||||
|
* because (a) ESPN rate-limits image hotlinking and (b) the league CDNs are the
|
||||||
|
* same sources PrizePicks and Sleeper use, so coverage is closer to 100%.
|
||||||
*
|
*
|
||||||
* Fallback chain inside the resolver:
|
* Fallback chain inside the resolver:
|
||||||
* 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer,
|
* 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
|
||||||
* where API-Football returns the photo in player responses), use
|
* soccer URLs (no central CDN for player headshots).
|
||||||
* that directly. We DO NOT construct soccer URLs because no
|
|
||||||
* central CDN exists for player headshots.
|
|
||||||
* 2. League CDN with `playerId` — official source.
|
* 2. League CDN with `playerId` — official source.
|
||||||
* 3. ESPN CDN fallback — only when `espnId` is present and no
|
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
|
||||||
* league ID is available. Helpful while the roster table is
|
* 4. `/images/player-silhouette.svg` — the sentinel the consuming component
|
||||||
* being backfilled with league-specific IDs.
|
* swaps for a team-colored MONOGRAM (never a gray blob, never a broken
|
||||||
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette.
|
* image). `<img onError>` also degrades a 404 to the monogram.
|
||||||
*
|
|
||||||
* `<img onError>` in the consuming component handles 404s from the
|
|
||||||
* CDN (e.g. a player who's in our roster but the league hasn't
|
|
||||||
* uploaded a headshot yet) by swapping to the silhouette.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getHeadshotUrl as coreGetHeadshotUrl,
|
||||||
|
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
|
||||||
|
} from '@/lib/playerHeadshotUrl';
|
||||||
|
|
||||||
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
|
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
|
||||||
|
|
||||||
export interface HeadshotInput {
|
export interface HeadshotInput {
|
||||||
sport: HeadshotSport;
|
sport: HeadshotSport;
|
||||||
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
|
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
|
||||||
playerId?: string | number | null;
|
playerId?: string | number | null;
|
||||||
/** Optional ESPN ID as a fallback when no league ID is known. */
|
/** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||||
espnId?: string | number | null;
|
espnId?: string | number | null;
|
||||||
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
|
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
|
||||||
cachedPhotoUrl?: string | null;
|
cachedPhotoUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
|
export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
|
||||||
|
|
||||||
const ESPN_SPORT_PATH: Record<string, string> = {
|
|
||||||
nba: 'nba',
|
|
||||||
wnba: 'wnba',
|
|
||||||
mlb: 'mlb',
|
|
||||||
// ESPN soccer headshots are inconsistent across leagues — explicitly
|
|
||||||
// omit so soccer falls through to silhouette unless a cached photo
|
|
||||||
// is provided.
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getHeadshotUrl(input: HeadshotInput): string {
|
export function getHeadshotUrl(input: HeadshotInput): string {
|
||||||
const sport = String(input.sport || '').toLowerCase();
|
return coreGetHeadshotUrl(input);
|
||||||
const playerId = input.playerId != null ? String(input.playerId) : '';
|
|
||||||
const espnId = input.espnId != null ? String(input.espnId) : '';
|
|
||||||
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
|
|
||||||
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
if (playerId) {
|
|
||||||
switch (sport) {
|
|
||||||
case 'nba':
|
|
||||||
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
|
|
||||||
case 'wnba':
|
|
||||||
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
|
|
||||||
case 'mlb':
|
|
||||||
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (espnId && ESPN_SPORT_PATH[sport]) {
|
|
||||||
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PLAYER_SILHOUETTE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper for the common case where the caller has a
|
* Convenience wrapper for the common case where the caller has a player object
|
||||||
* player object with mixed ID fields. Pulls the first non-empty ID
|
* with mixed ID fields. Pulls the first non-empty ID out of the union before
|
||||||
* out of the union before delegating to `getHeadshotUrl`.
|
* delegating to `getHeadshotUrl`.
|
||||||
*/
|
*/
|
||||||
export function headshotFromPlayer(player: {
|
export function headshotFromPlayer(player: {
|
||||||
sport?: string;
|
sport?: string;
|
||||||
|
|||||||
@@ -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',
|
'/dashboard',
|
||||||
'/slate',
|
'/slate',
|
||||||
'/scan',
|
'/scan',
|
||||||
|
/* Wave 4B — the Parlay Lab is the parlay-building funnel: anon/free reach it
|
||||||
|
(free 2-leg cap + payout-blur upsell), same monetization logic as scan +
|
||||||
|
dashboard, so it stays OPEN, not gated. */
|
||||||
|
'/parlay',
|
||||||
'/compare',
|
'/compare',
|
||||||
'/game',
|
'/game',
|
||||||
'/pricing',
|
'/pricing',
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
player: displayName(p.player),
|
player: displayName(p.player),
|
||||||
team: knownTeam,
|
team: knownTeam,
|
||||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||||
|
// Wave 2A — real headshot id threaded from the snapshot grade
|
||||||
|
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
|
||||||
|
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
|
||||||
|
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
|
||||||
lineup: lineupStatusFor(pk, knownTeam),
|
lineup: lineupStatusFor(pk, knownTeam),
|
||||||
injury: injuryFor(pk),
|
injury: injuryFor(pk),
|
||||||
stats: [],
|
stats: [],
|
||||||
@@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
const cand = displayName(p.player);
|
const cand = displayName(p.player);
|
||||||
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
|
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
|
||||||
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
|
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
|
||||||
|
// Fill an id from a later graded row if the first row lacked one.
|
||||||
|
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
|
||||||
|
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
|
||||||
}
|
}
|
||||||
if (rec) {
|
if (rec) {
|
||||||
const side = sideCh(rec.direction);
|
const side = sideCh(rec.direction);
|
||||||
@@ -596,6 +603,7 @@ module.exports = {
|
|||||||
isRelevantGame,
|
isRelevantGame,
|
||||||
indexGrades,
|
indexGrades,
|
||||||
indexDeltas,
|
indexDeltas,
|
||||||
|
gradeKey,
|
||||||
statShort,
|
statShort,
|
||||||
gradedAgo,
|
gradedAgo,
|
||||||
buildPlayerStripsFromProps,
|
buildPlayerStripsFromProps,
|
||||||
|
|||||||
@@ -195,7 +195,16 @@ function resolveTeam(key, sport) {
|
|||||||
const up = String(key).toUpperCase().trim();
|
const up = String(key).toUpperCase().trim();
|
||||||
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
||||||
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
||||||
const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' };
|
// Global alias table. resolveTeam checks the real abbr FIRST, so an entry
|
||||||
|
// only fires for a sport whose table lacks that key — e.g. WSH resolves
|
||||||
|
// directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to
|
||||||
|
// WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit
|
||||||
|
// (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here.
|
||||||
|
const ALIAS = {
|
||||||
|
ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD',
|
||||||
|
GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX',
|
||||||
|
SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON',
|
||||||
|
};
|
||||||
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
||||||
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
||||||
const a = SOCCER_ALIAS[normKey(key)];
|
const a = SOCCER_ALIAS[normKey(key)];
|
||||||
|
|||||||
@@ -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' },
|
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
|
||||||
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
||||||
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
|
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
|
||||||
|
// Wave 6 — combat: the #D4AF37 championship-gold token (matches
|
||||||
|
// src/services/shareCards/tokens.js + config/sports.js mma color).
|
||||||
|
mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* GradeBadge size variants — hero stays 80–120px (§5: grade letter is
|
/* GradeBadge size variants — hero stays 80–120px (§5: grade letter is
|
||||||
|
|||||||