diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 24bc271..8fb645d 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -242,6 +242,18 @@ Published only. Unknown AND unpublished return the SAME 404 body } ``` +### HOUSE profile (Wave 5A, D2) +The reserved handle `HOUSE_HANDLE` (env, default `vyndr`) resolves to the +PUBLIC model record (`getModelAggregate()` with NO userId → the `user_id=NULL` +ledger rows) WITHOUT a `public_profiles` row, and is ALWAYS public (the +partner-pitch weapon). Same response shape + two extra fields: +`{ ..., house: true, label: 'VYNDR MODEL · PUBLIC RECORD' }`. Entries are the +public settled rows (`user_id IS NULL`, misses included). Reserved before the +publish lookup — a user who claims it is shadowed. Every OTHER handle keeps the +private-by-default, byte-identical-404 contract. It never 404s (a fetch failure +degrades to an honest empty/building state). Portrait share crop: route handler +`GET /u/[handle]/portrait` → 1080×1350 PNG (real aggregate or tagline fallback). + ### `GET|POST /api/profiles/me` (requireAuth) GET → `{ profile: { handle, published, created_at } | null }`. POST `{ handle, published }` → upsert own row (service role). Handle must diff --git a/specs/STATE.md b/specs/STATE.md index 40aab5f..b8874fe 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,5 +1,15 @@ # VYNDR — STATE OF THE WORLD -### As of `e9c0a59` (main, 2026-07-13). This file opens every future session. Update it when a train ships. +### As of `e9c0a59` (main) + `wiring/data-train` READY, 2026-07-13. This file opens every future session. Update it when a train ships. + +## READY TO MERGE — WIRING & DATA TRAIN (branch `wiring/data-train`, NOT on main yet) +Governed by `specs/wiring-data-train.md` (the Step-0 MAP + build plans) + `specs/combat-intelligence.md` + the global visual reference `specs/design-reference/vyndr-system.html` (build toward it; live wordmark kept). **All 6 waves DONE, merged onto `wiring/data-train`, green (253 suites / 3069 tests) + `next build` exit 0. Awaiting the founder's word before main.** Real assets verified live (MLB/ESPN headshot CDNs, ESPN-MMA feed, Baseball Savant CSV). +- **Wave 1 — trust bugs:** billing renewal honest render (`billingDisplay.classifyRenewal` — no far-future placeholder); James Wood nameKey-collision fixed (`mlbStatsAdapter` teamHint disambiguation + streaks join-invariant); DeskShowcase "$1M terminal" → deadpan copy. +- **Wave 2 — sport-agnostic entity layer:** real player headshots threaded from ingestion (MLB MLBAM + NBA/WNBA ESPN athlete ids that were fetched-and-discarded) across slate/scan/hotlist/search/grade card; soccer = honest monogram (no free id); 8 self-authored SVG book wordmarks (`web/public/books/*.svg`, swappable for official art) + all 10 book keys resolve; team-logo abbr aliases. Storage: id on the `enriched` grade at `snapshotService` (zero new I/O). +- **Wave 3 — record by grade tier (Addition 2):** ONE shared `TierRecord` (`lib/tierRecord.js` + component) on dashboard + /u + ledger; per-tier W-L always, hit-% only at n≥20 per tier (gate stays in `getModelAggregate`). +- **Wave 4 — missing surfaces:** Outlook mode (grid never blank → yesterday receipts / tomorrow schedule); Market-Breadth median-consensus-vs-model strip (self-hides <2 books); Parlay Lab `/parlay` (slate-independent leg source); live Grade-Shift timeline (`GradeShift`). +- **Wave 5 — /u house-mode + arsenal:** house handle `vyndr` surfaces the real `user_id=NULL` public model record + per-tier calibration + 1080×1350 portrait/OG (user-handle privacy 404s stay byte-identical); Baseball Savant pitcher-arsenal (`savantAdapter`, free CSV, verified) → `PitcherArsenal` card, self-hides on absent. +- **Wave 6 — combat v1 (MMA):** ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes (sport-scoped `COMBAT_ARCHETYPES` — FINISHER color collided w/ soccer + tripped the ΔE gate, so kept separate) + odds-api ML/round-totals + style-edge verdict. NOT in the snapshot/settle loop; method/round/props + fighter photos + matchup-GRADE engine + ufcstats scraping all DEFERRED, flagged data-limited in-UI. New routes: `/fight/[id]`, `/parlay`, `/u/[handle]/portrait`. +POST-MERGE TODO: NBA/WNBA headshot coverage + combat depth need prod runtime verification; soccer headshots blocked on `API_FOOTBALL_KEY`; combat settlement + matchup-grade engine are the next combat sub-wave. ## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live) Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as diff --git a/specs/combat-intelligence.md b/specs/combat-intelligence.md new file mode 100644 index 0000000..05e9c7e --- /dev/null +++ b/specs/combat-intelligence.md @@ -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. diff --git a/specs/design-reference/support.js b/specs/design-reference/support.js new file mode 100644 index 0000000..6ca00e3 --- /dev/null +++ b/specs/design-reference/support.js @@ -0,0 +1,1768 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + + + +
+ + + + + +
+
+
+ + + ESC +
+
+
JUMP TO
+
+ + Tonight's Slate · Edge Board + +
+
+ + Ledger · settled history + +
+
+ + Streaks + +
+
PLAYERS
+
+ NJ + Nikola Jokić · C · DEN + A+ +
+
+ PIT + Paul Skenes · RHP · PIT + A +
+
+ MIN + Anthony Edwards · SG · MIN + A +
+
ACTIONS
+
+ + Generate share card + +
+
+ + Export /u public record + +
+ +
+
+ ↑ ↓ NAVIGATE · ↵ OPEN · ESC CLOSE + VYNDR TERMINAL +
+
+
+
+ + +
+
+
VYNDR
+
+
SPORTS INTELLIGENCE TERMINAL
+
+
+ +
+
+ 14 + EDGES +
+
+ +2.8% + AVG CLV +
+
+
+ + SYNC + --:--:-- +
+ +
$44.99/mo
+
+
+ +
+ + +
+
VISUAL SYSTEM · v2
+

The instrument, not the sportsbook.

+

Dense but ranked. Mostly still, then it reacts — a number pulses the instant a line moves, a card breathes when its game goes live. One hero per screen.

+
+ + +
+
+
+ TONIGHT'S SLATE + MULTI-LEAGUE · 47 GAMES · 312 PROPS GRADED +
+ RANKED BY EDGE +
+ +
+ +
+
+
+ EDGE BOARD + 14 LIVE +
+ SORTED · EDGE ▼ +
+
+
+ # + READ + GRD + EDGE +
+ +
+ 01 +
Jokić o27.5 PTS
NBA · DEN @ MIN
+ A+ + +8.4% +
+
+ 02 +
Skenes o6.5 K
MLB · PIT @ CHC
+ A + +6.1% +
+
+ 03 +
Edwards o5.5 AST
LIVE · Q3 4:12 · DEN @ MIN
+ A + +5.2% +
+
+ 04 +
Judge o1.5 TB
MLB · NYY @ BOS
+ B + +3.4% +
+
+ 05 +
McDavid o1.5 PTS
NHL · EDM @ CGY
+ B + +3.0% +
+
+ 06 +
Mahomes o274.5 PYD
NFL · KC @ BUF
+ B + +2.6% +
+
+ 07 +
Betts o1.5 TB
MLB · LAD @ SD
+ C + +2.1% +
+
+ 08 +
Brunson o24.5 PTS
NBA · NYK @ BOS
+ C + +1.4% +
+
+ 09 +
Curry o4.5 3PM
NBA · GSW @ SAC
+ C + +0.4% +
+
+ 10 +
LaVine o22.5 PTS
NBA · CHI @ MIL
+ D + -1.2% +
+
+ + 37 GRADED READS BELOW + VIEW ALL ▸ +
+
+ + +
+
+
+
+ DECLASSIFIED · TOP GRADE TONIGHT + CLV-VERIFIED +
+ +
+
+
A+
+
TIER
+
+
+
+ NJ +
+
Nikola Jokić
+
C · DEN · CONFIRMED
+
+
+
Over 27.5 Points
+
+ DK -114 + + opened 25.5 +
+
+
+ + +
+
+
EDGE
+
+8.4%
+
+
+
L10 HIT
+
8/10
+
+
+
PROJ
+
30.8
+
+
+ + +
+
+ + + + + + + + + + +
+
+ HIT + MISS +
+
+ + +
+
+ + VYNDR INTELLIGENCE +
+

MIN plays the 4th-fastest pace vs. centers and concedes +3.1 rebounds to CONDUCTOR-type bigs. Line lagged the pace matchup by 2.0 — the steam confirms it.

+
+
+
+
+
+ + +
+
+ COMPONENT SYSTEM + THE PARTS EVERY SURFACE INHERITS +
+
+ +
+ + +
+
02 · STAT BLOCK
+
+
+ PTS / GAME · L15 + ▲ +2.3 +
+
+ 28.6 +
+
#4 of 82 C
+
vs MIN allows 26.1
+
+
+
+ + + + + + + + + + + + L10 +
+
+
+ + +
+
03 · PLAYER IDENTITY
+
+
+
NJ
+ HEADSHOT SLOT +
+
+
+ Nikola Jokić + CONFIRMED +
+
C · #15 · Denver Nuggets
+
+ + + CONDUCTOR + + + + TORCH + +
+
+
+
+ + +
+
04 · LINE DISPLAY
+
+
+ Jokić o27.5 PTS + STEAM ▲ +
+
+
+ DraftKings +
+ -114 + BEST +
+
+
+ FanDuel +
+ -120 + +
+
+
+ BetMGM +
+ -118 + +
+
+
+
+ CLV vs CLOSE + +2.4% +
+
+
+ + +
+
05 · GRADE REVEAL · tiers
+
+
+
+
A
+
EDGE
+
+
+
B
+
LEAN
+
+
+
C
+
PASS
+
+
+
D
+
FADE
+
+
+
F
+
AVOID
+
+
+
+
+ GLOW RESERVED FOR A-TIER ONLY + EDGE COLORED BY SIGN +
+
+
+
+
+ + +
+
+ ARCHETYPE GLYPHS + FULL SYSTEM · 74 UNIQUE MARKS · 8 SPORTS +
+
+ +

One grid, one weight, one construction logic — filled base plus weighted stroke, so each mark reads as an emblem before the word. Colors deduped off signal-green so #00D4A0 stays uniquely "edge."

+

marks names reused across sports — the vocabulary is a language: a WALL is the immovable last line in every sport.

+ + +
+ + +
+
+
THE BLEND · MULTI-ARCHETYPE FINGERPRINT
+

Players aren't one archetype — they carry a weighted blend. The mark system combines: primary dominant + supporting muted. This is the moat nobody copies.

+
+
+
+ TB +
+
Brandon Lowe
+
2B · Tampa Bay Rays
+
+
+
+
+
+ +
+
BOMBER
+
+
+
+ PRIMARY · BOMBER + 54% +
+
+
+ SUPPORTING · DRIVER + 46% +
+
+
+
+
+ +
+
DRIVER
+
+
+
+
+
+ + +
+
+ STREAKS + A BLOOMBERG ALERT, NOT A LOG LINE +
+
+ +
+ +
+
LA
+
+
+ Luka Dončić + + + TORCH + +
+
o29.5 PTS · built vs bottom-10 defenses
+
+
+
+ 11 + STRAIGHT +
+ HIT RATE 100% · L11 +
+
+ +
+
TOR
+
+
+ Vladimir Guerrero Jr. + + + BOMBER + +
+
o1.5 TB · correlated w/ team total
+
+
+
+ 7 + STRAIGHT +
+ CAUTION · CORRELATION FLAG +
+
+
+ +
+ + +
+
+ PUBLIC RECORD + vyndr.io/u/edgehunter · THE VERIFIED WEAPON +
+
+ +
+ +
+
EH
+
+
+ @edgehunter + CLV-VERIFIED +
+
+ + + TORCH-HEAVY BETTOR + + 412 graded · since Mar '24 +
+
+ +
+ + +
+
+
HIT RATE
+
64.2%
+
+
+
UNITS
+
+38.6u
+
+
+
ROI
+
+11.4%
+
+
+
AVG CLV
+
+3.1%
+
+
+ + +
+
+ GRADE HISTORY · LAST 24 +
+ HIT + MISS +
+
+
+
+ + +
+
+
ARCHETYPE FINGERPRINT
+
+
+
+ TORCH + 32% +
+
+
+
+
+ BOMBER + 24% +
+
+
+
+
+ ALPHA + 18% +
+
+
+
+
+ CONDUCTOR + 14% +
+
+
+
+
+ +
+
RECENT SETTLED
+
+
+
Jokić o27.5 PTS
DEN · settled 30 · CLV +2.4%
+
AHIT
+
+
+
Judge o1.5 TB
NYY · settled 2 · CLV +1.1%
+
BHIT
+
+
+
Edwards o5.5 REB
MIN · settled 4 · CLV −0.8%
+
CMISS
+
+
+
Skenes o6.5 K
PIT · settled 8 · CLV +4.0%
+
A+HIT
+
+
+
+
+
+
+ + +
+
+ SHARE CARDS + STOP-THE-SCROLL · TIMELINE IS CUSTOMER #1 +
+
+ +
+ + +
+
THE SETTLE · 1080 × 1350
+
+
+
+ VYNDR + JUL 11 +
+
+
DAILY SETTLE
+
+ +5.4u +
+
7–2 · 78% HIT · 9 GRADED
+
+
+
+ Jokić o27.5 PTS + A+HIT +
+
+ Skenes o6.5 K + AHIT +
+
+ Edwards o5.5 REB + CMISS +
+
+ Judge o1.5 TB + BHIT +
+
+
+ @edgehunter + CLV-VERIFIED +
+
+
+
+ + +
+
GRADE REVEAL · 1200 × 630 OG
+
+
+
+ VYNDR + CLV-VERIFIED +
+
+
+ A+ + TIER +
+
+
Nikola Jokić
+
Over 27.5 Points
+
+
EDGE
+8.4%
+
L10
8/10
+
DK
−114
+
+
+
+
vyndr.io/u/edgehunter
+
+
+
+
+
+ + +
+
+ STYLE MATCHUP + COMBAT · STYLES MAKE FIGHTS · FIGHT-WEEK WEAPON +
+
+ +
+
+ UFC 312 · LIGHTWEIGHT · 5 RND MAIN + CLV-VERIFIED READ +
+ +
+ +
+
+
IM
+
Islam M.
26–1 · ORTHODOX · 70" REACH
+
+
GRAPPLER80%
+
STRIKER30%
+
+ COMBAT SAMBO + DAGESTAN WRESTLING + BJJ BROWN +
+
+ + +
+
VERDICT
+
VS
+
GRAPPLER EDGE
+
+ + +
+
+
AV
+
Alex V.
26–3 · ORTHODOX · 71.5" REACH
+
+
PRESSURE72%
+
TECHNICIAN44%
+
+ KICKBOXING + ELITE TDD 94% + ?WRESTLING BASE +
+
+
+ + +
+

Takedown defense is the whole fight. Islam's chain-wrestling beats a pure striker — but Volk's 94% TDD is the one variable that flips it.

+
+ RECENT-FORM OVERRIDE + Volk stuffed 11 of 12 TDs last 3 fights → pushes this toward DECISION, not submission. +
+
+
+
MONEYLINE
A-Islam -230
+
DECISION
A+120
+
SUBMISSION
B++280
+
KO / TKO
C+650
+
O 2.5 RND
A--140
+
+
+
+ + +
+
+ SYSTEM SURFACES + LIVE GRADE-SHIFT · THE $44.99 TIER · EMPTY STATE +
+
+ +
+ + +
+
+
LIVE GRADE
+ Q3 4:12 · DEN @ MIN +
+
+
+
+
A+
+
LIVE TIER
+
+
+
Edwards o5.5 AST
+
GRADE FIRMED ▲ 04:12
+
+ PACE + USAGE + MATCHUP +
+
+
+ +
+
GRADE · BY GAME CLOCK+6.8%
+
+
+
+
+
+
+
+
+
+
+
TIPNOW
+
+
+
+ + +
+
+
+
+ VYNDR PRO + FREEPRO +
+
+ $44.99 + /mo +
+

Priced like a subscription. Performs like a $1M desk. Free gives you live scores + tonight's top 3 grades — Pro unlocks the whole board.

+
+ Every graded read, every league, every night + CLV-verified public record + share cards + Live grade-shift + steam alerts + Archetype fingerprints + combat style engine +
+ +
cancel anytime · no trial — the free tier is the trial
+
+
+
+ + +
+
+
+
TRANSMISSION QUIET
+
No reads cleared the A-tier threshold yet.
+

The engine graded 312 props tonight — none hit the edge floor. That's the system protecting you, not a bug. Grades refresh as lines move.

+
+ + +
+
+
+
+ + +
+
+ PITCHER IDENTITY + THE ARSENAL READ · ONE IDENTITY → MANY PROPS +
+
+ +
+
+ +
+
+
PIT
+
+
Paul SkenesCONFIRMED
+
RHP · #30 · Pittsburgh · vs CHC
+
+
+
IDENTITY BLEND
+
+ ALPHA + PUNCHOUT +
+
+
APPROACH · TUNNELER
+

Splinker + slider tunnel off the 99mph 4-seam — hitters commit early and miss. Whiffs cluster the 3rd time through.

+
+
+ + +
+
+ PITCH + VELO + USE + WHIFF +
+
4-Seam99.132%26%
+
Splinker94.224%38%
+
Slider87.022%41%
+
Curve82.514%33%
+
+
+ +
+
STRIKEOUTS O6.5
A+6.1%
+
OUTS O16.5
A-+4.2%
+
EARNED RUNS U2.5
B++2.4%
+
1ST-INNING K
B+1.6%
+
+
+
+ + +
+
+ CORRELATION BUILDER + PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD +
+
+ +
+ +
+
TONIGHT'S A/B READSTAP TO ADD
+
+
+ +
Jokić o27.5 PTS
NBA · DEN @ MIN
+ A+ + +105 +
+
+ +
Edwards o5.5 AST
NBA · DEN @ MIN
+ A + +130 +
+
+ +
Skenes o6.5 K
MLB · PIT @ CHC
+ A + -120 +
+
+ +
Judge o1.5 TB
MLB · NYY @ BOS
+ B + +140 +
+
+
+ + +
+
+ PARLAY SLIP + 2 LEGS +
+
+
+
SAME-GAME CORRELATION
+ +
+
+
COMBINED
+320
+
GRADE
B
+
VYNDR STAKE
0.4u
+
+
+
+
+ +
+
+ COMPONENTS · 74 GLYPHS · EDGE BOARD · /u · SHARE · COMBAT · LIVE GRADE · PRO · STATES + VYNDR VISUAL SYSTEM · v2 +
+
+ +
+
+
+ + + diff --git a/specs/wiring-data-train.md b/specs/wiring-data-train.md new file mode 100644 index 0000000..52b68a2 --- /dev/null +++ b/specs/wiring-data-train.md @@ -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 `` → 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 `` 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 ``) **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). `` 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. diff --git a/src/app.js b/src/app.js index a56485c..b8e6f7f 100644 --- a/src/app.js +++ b/src/app.js @@ -152,6 +152,12 @@ app.use('/api/widget', widgetRoutes); // stat-filtered views over all of them. const scheduleRoutes = require('./routes/schedule'); app.use('/api/schedule', scheduleRoutes); +// Wave 6 — combat intelligence (MMA/UFC): fight cards + tale-of-the-tape + +// best-effort ML/round-total odds. Read-only, cache-friendly, honest empty +// off-card. NOT in the graded-props pipeline (no settled grades in v1). +const combatRoutes = require('./routes/combat'); +app.use('/api/combat', combatRoutes); +app.use('/api/fight', combatRoutes.fightRouter); // Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public, // cache-only, never triggers a snapshot. const tickerRoutes = require('./routes/ticker'); diff --git a/src/config/sports.js b/src/config/sports.js index 801ed4a..a98dd83 100644 --- a/src/config/sports.js +++ b/src/config/sports.js @@ -41,7 +41,11 @@ const SPORTS = Object.freeze({ nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, - mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, + // Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards + + // tale-of-the-tape + style blend + ML/round-total odds). It is NOT in the + // graded-props pipeline (SPORT_CONFIG) or the snapshot/settle loop yet, so + // collectData stays false — active flips true so the UI treats it as live. + mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, }); diff --git a/src/routes/combat.js b/src/routes/combat.js new file mode 100644 index 0000000..1a00ea3 --- /dev/null +++ b/src/routes/combat.js @@ -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; diff --git a/src/routes/profiles.js b/src/routes/profiles.js index 8e0f753..402c395 100644 --- a/src/routes/profiles.js +++ b/src/routes/profiles.js @@ -19,6 +19,13 @@ * PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit * toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle * return the byte-identical 404 body. + * + * HOUSE PROFILE (Wave 5A, D2) — the reserved `HOUSE_HANDLE` (default `vyndr`) + * resolves to the PUBLIC model record (`getModelAggregate()` with NO userId → + * the `user_id = NULL` ledger rows). It needs NO public_profiles row and is + * ALWAYS public (it's the partner-pitch weapon: the real house record). It is + * the ONLY special case; every OTHER handle keeps the private-by-default, + * no-existence-leak contract intact. */ const express = require('express'); @@ -30,6 +37,10 @@ const router = express.Router(); router.use(createRateLimit({ windowMs: 60_000, max: 60 })); const HANDLE_RE = /^[a-z0-9_]{3,20}$/; +// Reserved house handle → the public model record (user_id = NULL). Operators +// can override via env; it must still satisfy HANDLE_RE to be reachable. +const HOUSE_HANDLE = String(process.env.HOUSE_HANDLE || 'vyndr').trim().toLowerCase(); +const HOUSE_LABEL = 'VYNDR MODEL · PUBLIC RECORD'; // Same 404 body for unknown AND unpublished — never confirm a handle exists. const NOT_FOUND = { error: 'Profile not found' }; // Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS). @@ -94,6 +105,10 @@ router.get('/:handle', async (req, res) => { const handle = String(req.params.handle || '').trim().toLowerCase(); // Invalid shape can't exist (DB CHECK) → same 404, no query needed. if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND); + // HOUSE handle → the public model record. Checked BEFORE the + // public_profiles lookup so the handle is reserved (a user who claims it is + // shadowed). This is the ONLY handle that bypasses the publish gate. + if (handle === HOUSE_HANDLE) return sendHouseProfile(res); const sb = sbOrNull(); if (!sb) return res.status(404).json(NOT_FOUND); try { @@ -131,4 +146,52 @@ router.get('/:handle', async (req, res) => { } }); +/** + * The house/model profile — the PUBLIC model record (user_id = NULL), served + * as a shareable /u profile WITHOUT a public_profiles row. Same response shape + * the page already consumes (aggregate + by_tier + settled entries), plus + * `house: true` + a label so the UI can distinguish it from a user profile. + * It always "exists" → a fetch failure degrades to an honest empty/building + * state, never a 404. + */ +async function sendHouseProfile(res) { + const sb = sbOrNull(); + try { + // NO userId → the public `user_id = NULL` aggregate (the real house + // record + Wave-3 by_tier). getModelAggregate self-empties without env. + const aggregate = await ledgerService.getModelAggregate(sb ? { sb } : {}); + let entries = []; + if (sb) { + // ALL public settled reads, misses included — nothing curated. + const { data, error } = await sb.from('ledger_entries') + .select(ROW_COLUMNS) + .is('user_id', null) + .not('outcome', 'is', null) + .order('graded_at', { ascending: false }) + .limit(ENTRY_LIMIT); + if (error) throw new Error(error.message); + entries = data || []; + } + res.set('Cache-Control', 'public, max-age=60'); + return res.json({ + handle: HOUSE_HANDLE, + house: true, + label: HOUSE_LABEL, + aggregate, + entries, + min_sample: ledgerService.MIN_AGG_SAMPLE, + }); + } catch (err) { + console.error('[profiles/house]', err.message); + return res.status(200).json({ + handle: HOUSE_HANDLE, + house: true, + label: HOUSE_LABEL, + aggregate: null, + entries: [], + min_sample: ledgerService.MIN_AGG_SAMPLE, + }); + } +} + module.exports = router; diff --git a/src/routes/stats.js b/src/routes/stats.js index e44fbe5..5af2525 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -2,7 +2,8 @@ const express = require('express'); const { getSupabaseServiceClient } = require('../utils/supabase'); const { getStatFilters } = require('../config/statFilters'); const { createRateLimit } = require('../middleware/rateLimit'); -const { getPlayerIntel, getLeaders } = require('../services/playerIntelService'); +const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService'); +const { getPitcherArsenal } = require('../services/adapters/savantAdapter'); const depthChart = require('../services/depthChartService'); const router = express.Router(); @@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => { } }); +// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B). +// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast +// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other +// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates. +router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => { + try { + const sport = String(req.query.sport || 'mlb').toLowerCase(); + if (sport !== 'mlb') { + return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' }); + } + const name = sanitizePlayerName(req.params.name); + const arsenal = await getPitcherArsenal(name); + res.set(MISSION_HEADER).json(arsenal || { found: false }); + } catch (err) { + console.error('[stats/pitcher/arsenal]', err.message); + // Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides. + res.set(MISSION_HEADER).json({ found: false }); + } +}); + // GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top // graded props by confidence) for the Terminal / Stats Explorer. router.get('/leaders', intelLimit, async (req, res) => { diff --git a/src/services/adapters/combatAdapter.js b/src/services/adapters/combatAdapter.js new file mode 100644 index 0000000..0845e7f --- /dev/null +++ b/src/services/adapters/combatAdapter.js @@ -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, +}; diff --git a/src/services/adapters/espnStatsAdapter.js b/src/services/adapters/espnStatsAdapter.js index 2c90265..5b99a13 100644 --- a/src/services/adapters/espnStatsAdapter.js +++ b/src/services/adapters/espnStatsAdapter.js @@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) { const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id)); if (!id) return { found: false }; + // Wave 2A — the REAL ESPN athlete id for the headshot CDN + // (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the + // pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot + // id, so it degrades to null → monogram. Never fabricate. + const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null; + const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null; + // 2. Fetch that athlete's stats overview. const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http); const classifierInput = parseAthleteStats(stats); @@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) { team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '', position: (athlete.position && athlete.position.abbreviation) || '', classifierInput, + // Wave 2A — surfaced so resolvePlayerStats can thread it to the grade → + // slate strip → headshot. Absent → monogram (doctrine). + espnId, }; try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ } return result; diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 200b399..ba0bcee 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) { return matchPlayers(people, query, opts.limit || 12); } -async function searchPlayer(name, season = DEFAULT_SEASON) { +// ── Namesake disambiguation (Wave 1 · trust bug) ───────────────────────────── +// Two different players can share an EXACT nameKey ("James Wood" — the Nationals +// star + a Cubs-affiliate namesake). Taking the first `.find` match silently +// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in +// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a +// confident team hint (the prop's game participants); otherwise refuse (null). + +// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs. +// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal. +const ABBR_ALIAS = Object.freeze({ + AZ: 'ARI', ARI: 'ARI', + CHW: 'CWS', CWS: 'CWS', + WSN: 'WSH', WSH: 'WSH', + SDP: 'SD', SD: 'SD', + SFG: 'SF', SF: 'SF', + TBR: 'TB', TB: 'TB', + KCR: 'KC', KC: 'KC', +}); +function canonAbbr(a) { + const u = String(a || '').toUpperCase().trim(); + return ABBR_ALIAS[u] || u; +} +function teamNorm(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +/** + * Does a resolved team record (`{ id, name }`, from a candidate's currentTeam) + * match ANY identifier in the prop's team hint? A hint entry may be an abbr + * ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals"). + * teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn + * an abbr hint into a team id. + */ +function teamRecordMatchesHint(team, teamHint, teams) { + if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false; + const tId = team.id; + const tName = teamNorm(team.name); + for (const h of teamHint) { + if (!h) continue; + // 1) hint as a name (equal / either-contains — handles "Nationals" vs full) + const hn = teamNorm(h); + if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true; + // 2) hint as an abbr → resolve to a team id via the teams list, compare ids + const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h)); + if (rec && tId != null && rec.id === tId) return true; + } + return false; +} + +/** Among namesake candidates, return the SINGLE one whose currentTeam matches + * the hint, else null (2+ or 0 matches → refuse; never guess). */ +function disambiguateByHint(candidates, teamHint, teams) { + if (!Array.isArray(teamHint) || teamHint.length === 0) return null; + const matches = (candidates || []).filter((p) => + p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams)); + return matches.length === 1 ? matches[0] : null; +} + +/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial + * hit, else null. A missing profile beats another player's log. */ +function lastNameInitialFallback(people, targetKey) { + const parts = targetKey.split(' '); + const first = parts[0] || ''; + const last = parts[parts.length - 1] || ''; + if (!(first && last && first !== last)) return null; + const cands = (people || []).filter((p) => { + const k = nameKey(p.fullName).split(' '); + return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; + }); + return cands.length === 1 ? cands[0] : null; +} + +/** + * Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game + * team identifiers) disambiguates namesakes AND enforces the join invariant: + * when a hint is present but the resolved player's team is NOT a participant of + * the prop's game, the team is DROPPED (returned null) rather than tagging a + * foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests. + */ +async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { const targetKey = nameKey(name); if (!targetKey) return null; - const url = `${BASE}/sports/1/players?season=${season}`; - const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); - const people = (data && Array.isArray(data.people)) ? data.people : []; - let hit = people.find((p) => nameKey(p.fullName) === targetKey); - if (!hit) { - const parts = targetKey.split(' '); - const first = parts[0] || ''; - const last = parts[parts.length - 1] || ''; - if (first && last && first !== last) { - const cands = people.filter((p) => { - const k = nameKey(p.fullName).split(' '); - return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; - }); - if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess + + let people = opts.people; + if (!Array.isArray(people)) { + const url = `${BASE}/sports/1/players?season=${season}`; + const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); + people = (data && Array.isArray(data.people)) ? data.people : []; + } + + const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null; + let teams = Array.isArray(opts.teams) ? opts.teams : null; + const ensureTeams = async () => { + if (teams) return teams; + try { teams = await getTeams(season); } catch { teams = []; } + return teams; + }; + + const exact = people.filter((p) => nameKey(p.fullName) === targetKey); + let hit = null; + let teamConfirmed = true; // stays true when there's no hint to check against + + if (exact.length === 1) { + hit = exact[0]; + if (teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); + } + } else if (exact.length >= 2) { + // Namesake collision — resolve ONLY with a confident hint, else refuse. + hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null; + // a hit here is team-confirmed by construction. + } else { + hit = lastNameInitialFallback(people, targetKey); + if (hit && teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); } } + if (!hit) return null; return { id: hit.id, fullName: hit.fullName ?? name, - team: hit.currentTeam?.name ?? null, - teamId: hit.currentTeam?.id ?? null, + team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null, + teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null, position: hit.primaryPosition?.abbreviation ?? null, }; } @@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) { * last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns * { found: false } on any miss/failure (never throws). */ -async function getPlayerStats(name, season = DEFAULT_SEASON) { +async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) { try { - const person = await searchPlayer(name, season); + const person = await searchPlayer(name, season, opts); if (!person) return { found: false }; const group = person.position === 'P' ? 'pitching' : 'hitting'; const [seasonStat, log] = await Promise.all([ @@ -293,5 +394,9 @@ module.exports = { getTeams, resolveTeam, getTeamRoster, - __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, + __internals: { + BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, + // Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures). + canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback, + }, }; diff --git a/src/services/adapters/savantAdapter.js b/src/services/adapters/savantAdapter.js new file mode 100644 index 0000000..f464c7e --- /dev/null +++ b/src/services/adapters/savantAdapter.js @@ -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, + }, +}; diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 3c88931..6a5c080 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -272,7 +272,40 @@ const ARCHETYPES = { }, }; +// ── Combat archetype registry (Wave 6 — MMA/UFC) ──────────────────── +// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry +// (NOT merged into ARCHETYPES) for two reasons: +// 1. FINISHER collides with the soccer archetype name — combat FINISHER +// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES +// lookup is keyed by uppercase name with no sport dimension. +// 2. Combat FINISHER's green (#12B886) sits close to the signal green, +// which the colorContract gate forbids for shared player archetypes. +// Isolating combat keeps that gate (edge-green purity) intact while +// honoring the pinned combat palette. +// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP; +// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs), +// same discipline as the cross-sport color-match test. +const COMBAT_ARCHETYPES = { + STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet — volume + power at range.' }, + GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: '⊗', axis: 'range', description: 'Fight hits the mat on his terms — control + subs.' }, + PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➤', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' }, + COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: '◊', axis: 'tempo', description: 'Patient — punishes what you show him.' }, + FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: '▲', axis: 'outcome', description: 'Ends nights — high KO/SUB rate.' }, + GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: '▦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' }, +}; + +// Discipline pedigree tags — VERIFIABLE credentials only (rendered separately +// from the archetype blend). Never inferred/guessed: absent when unknown. +const DISCIPLINE_PEDIGREES = [ + 'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base', + 'Kickboxing', 'Muay Thai', 'Boxing', +]; + const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0); +// Strict presence check — an ABSENT stat must not score an axis (Number(null) +// === 0 would fabricate a "0 output" claim). Only a real finite number counts. +const has = (v) => typeof v === 'number' && Number.isFinite(v); +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); /** NBA scorers — VYNDR Original keys. */ function scoreNBA(s) { @@ -358,7 +391,76 @@ function scoreMLB(s) { }; } -const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB }; +/** + * MMA fighter scorer. Style is a BLEND across three axes: + * range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs) + * tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense) + * outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance) + * + * Best-effort from what the ESPN feed exposes; ESPN's striking/grappling + * granularity is THINNER than ufcstats. Every axis scores ONLY when its + * inputs are real finite numbers — thin data yields fewer style claims, + * never a fabricated one. Inputs (all optional): + * slpm/sapm sig strikes landed/absorbed per min + * strAcc/strDef striking accuracy / defense (0-1) + * tdAvg takedowns per 15 + * subAvg sub attempts per 15 + * koRate/subRate/decRate fraction of WINS by method (0-1) + * koWins/subWins/decWins method counts (rates derived if rates absent) + */ +function scoreMMA(s = {}) { + const out = {}; + + // ── range axis ── + if (has(s.slpm)) { + let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite + if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker + if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15; + if (v > 0) out.STRIKER = clamp01(v); + } + if (has(s.tdAvg) || has(s.subAvg)) { + let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control + if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5; + if (v > 0) out.GRAPPLER = clamp01(v); + } + + // ── tempo axis ── + if (has(s.slpm) && has(s.sapm)) { + const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure + if (vol > 0) out.PRESSURE = vol; + } + if (has(s.strDef) || has(s.strAcc)) { + let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0; + if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4; + if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter + if (v > 0) out.COUNTER = clamp01(v); + } + + // ── outcome axis ── + const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s); + const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s); + const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s); + if (koR != null || subR != null) { + const finish = (koR || 0) + (subR || 0); + if (finish > 0) out.FINISHER = clamp01(finish); + } + if (decR != null && decR > 0) out.GRINDER = clamp01(decR); + + return out; +} + +// Derive a method rate from a win count when explicit rates are absent. +// Returns null (not 0) when totals are unknown — absent, never fabricated. +function deriveRate(count, s) { + if (!has(count)) return null; + const total = has(s.totalWins) + ? s.totalWins + : (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0); + if (!total || total <= 0) return null; + return clamp01(count / total); +} + +const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA }; /** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */ function getArchetype(name) { @@ -370,6 +472,13 @@ function getArchetype(name) { return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null; } +/** Look up a COMBAT archetype descriptor by name (case-insensitive). */ +function getCombatArchetype(name) { + if (!name) return null; + const key = String(name).toUpperCase(); + return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null; +} + /** * Classify a player. Returns: * { sport, primary, secondary|null, blend: [{archetype, weight}] } @@ -384,7 +493,12 @@ function classify(sport, stats = {}) { .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]); + // Combat is HONEST-empty on thin data: no forced fallback archetype (the + // other sports fall back to a low-usage role, but inventing a fighter's + // style from no data would be a fabrication — spec §STYLE-MATCHUP). + const resolver = sp === 'mma' ? getCombatArchetype : getArchetype; if (ranked.length === 0) { + if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] }; const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR'; return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] }; } @@ -393,23 +507,88 @@ function classify(sport, stats = {}) { const total = top.reduce((sum, [, v]) => sum + v, 0) || 1; const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) })); - const primary = getArchetype(ranked[0][0]); + const primary = resolver(ranked[0][0]); const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4 - ? getArchetype(ranked[1][0]) + ? resolver(ranked[1][0]) : null; return { sport: sp, primary, secondary, blend }; } +/** Weight of an archetype within a blend (0 when absent). */ +function blendWeight(blend, name) { + const hit = (blend || []).find((b) => b.archetype === name); + return hit ? hit.weight : 0; +} + +// Accept either a classify() result ({ blend }) or a raw blend array. +function asBlend(x) { + if (Array.isArray(x)) return x; + if (x && Array.isArray(x.blend)) return x.blend; + return []; +} + +/** + * styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's + * CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated + * confidence. Compares two style blends; when the data is too thin or the + * styles are too close to call, it says so honestly. + * + * a/b may be classify('mma', …) results or raw blend arrays. + * Returns { verdict, edgeSide: 'a'|'b'|null, summary }. + */ +const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER']; +const MIN_EDGE = 0.2; // below this stylistic gap → too close to call + +function styleMatchup(a, b) { + const A = asBlend(a); + const B = asBlend(b); + if (A.length === 0 || B.length === 0) { + return { + verdict: 'INSUFFICIENT READ', + edgeSide: null, + summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.', + }; + } + + let best = null; + for (const ax of STYLE_AXES) { + const diff = blendWeight(A, ax) - blendWeight(B, ax); + if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff }; + } + + if (!best || Math.abs(best.diff) < MIN_EDGE) { + return { + verdict: 'STYLES EVEN', + edgeSide: null, + summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.', + }; + } + + const edgeSide = best.diff > 0 ? 'a' : 'b'; + return { + verdict: `${best.ax} EDGE`, + edgeSide, + summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`, + }; +} + const classifyNBA = (stats) => classify('nba', stats); const classifyWNBA = (stats) => classify('wnba', stats); const classifyMLB = (stats) => classify('mlb', stats); +const classifyMMA = (stats) => classify('mma', stats); + module.exports = { ARCHETYPES, + COMBAT_ARCHETYPES, + DISCIPLINE_PEDIGREES, getArchetype, + getCombatArchetype, classify, classifyNBA, classifyWNBA, classifyMLB, + classifyMMA, + styleMatchup, }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index f2f350c..f2587b8 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -56,6 +56,11 @@ const SPORT_KEYS = { // keys added in Session 31; NHL keys were added alongside this wiring. nfl: 'americanfootball_nfl', nhl: 'icehockey_nhl', + // MMA / UFC (Wave 6 — combat intelligence). odds-api sport key for the + // moneyline + round-total feed. PropLine carries NO combat → odds-api-only + // (combat never enters the abundant player-props path). Off-card windows + // return an empty events array and the combat surface self-hides honestly. + mma: 'mma_mixed_martial_arts', // Soccer (Session 7j) — odds-api sport keys verified against // https://the-odds-api.com/sports-odds-data/sports-apis.html soccer_wc: 'soccer_fifa_world_cup', @@ -142,6 +147,10 @@ const SOCCER_MARKETS = [ 'player_passes', 'team_clean_sheet', ]; +// MMA / UFC (Wave 6) — GAME-level markets only: moneyline (h2h) + round total +// (totals). No player props on the free feed → NO 'spreads' suffix (unlike the +// player-prop sports, whose buildMarketString appends spreads). +const MMA_MARKETS = ['h2h', 'totals']; function buildMarketString(markets) { return [...markets, 'spreads'].join(','); @@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({ mlb: buildMarketString(MLB_MARKETS), nfl: buildMarketString(NFL_MARKETS), nhl: buildMarketString(NHL_MARKETS), + // MMA carries no player-prop spreads → join the game-level markets directly. + mma: MMA_MARKETS.join(','), ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA // Every soccer league code shares the same market set. ...Object.fromEntries( @@ -499,6 +510,8 @@ module.exports = { getCacheKey, SPORT_KEYS, SOCCER_SPORT_KEYS, + // Wave 6 — combat game-level markets (moneyline + round total). + MMA_MARKETS, // Session 16 — per-sport market scoping. SPORT_MARKETS, getMarketsForSport, diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 64a9ba8..4cc57b5 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) { try { if (sp === 'mlb') { const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); - const res = await mlb.getPlayerStats(name); + // Wave 1 — thread the prop's game team hint so a namesake collision + // (two "James Wood") resolves to the RIGHT player, and a team that isn't + // a participant of the prop's game is dropped (never a foreign tag). + const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint }); if (!res || !res.found) return { found: false }; const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season); // Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G) @@ -162,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) { const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) }); - return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] }; + // Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the + // adapter. Absent → no id → monogram. Never guessed. + return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null }; } return { found: false }; } diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 4479b43..8446270 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -298,6 +298,25 @@ async function runSnapshot(sport, opts = {}) { // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // and the slate join guard (a prop only attaches to its own game). const teamByPlayer = {}; + // Wave 2A — the REAL athlete id from the SAME stats resolve, keyed by player. + // MLB → MLBAM id (mlbstatic headshot CDN); NBA/WNBA → ESPN athlete id + // (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to + // grades:{sport} → slate strips → PlayerAvatar. Zero new I/O. Absent → the + // component falls to a team-colored monogram (never a fabricated face). + const playerIdByPlayer = {}; + const espnIdByPlayer = {}; + // Wave 1 (trust bug) — the prop's game participants become the resolve's + // teamHint: it disambiguates namesake collisions (two "James Wood") and, when + // the resolved player's real team isn't in the prop's game, the resolver drops + // the team rather than tag a foreign one (the streaks/rosterlogs JOIN + // INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name. + const teamHintByPlayer = {}; + for (const p of props || []) { + const k = norm(p.player); + if (!k || teamHintByPlayer[k]) continue; + const hint = [p.home_team, p.away_team].filter(Boolean); + if (hint.length) teamHintByPlayer[k] = hint; + } // Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks + // hot lists) starved because its data producers were all external and // unarmed (tank01-prefetch via n8n, the offline Python grading flow). @@ -308,11 +327,15 @@ async function runSnapshot(sport, opts = {}) { const logEntries = []; await mapLimit(players, STATS_CONCURRENCY, async (player) => { try { - const stats = await deps.resolveStats(player, sp); + const teamHint = teamHintByPlayer[norm(player)] || null; + const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {}); if (stats && stats.found) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; if (stats.team) teamByPlayer[player] = stats.team; + // Wave 2A — capture the resolved athlete id (headshot thread). + if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId; + if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId; if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) { logEntries.push({ name: normalizeName(player).display || player, @@ -328,12 +351,19 @@ async function runSnapshot(sport, opts = {}) { }); await mergeRosterLogs(sp, logEntries, deps); - const enriched = graded.map((g) => ({ - ...g, - gradedAt: gradedAtFor(g, oddsByKey, ts), - archetype: archByPlayer[g.player || g.player_name] || null, - team: teamByPlayer[g.player || g.player_name] || g.team || null, - })); + const enriched = graded.map((g) => { + const pn = g.player || g.player_name; + return { + ...g, + gradedAt: gradedAtFor(g, oddsByKey, ts), + archetype: archByPlayer[pn] || null, + team: teamByPlayer[pn] || g.team || null, + // Wave 2A — real headshot id (MLBAM for MLB, ESPN for NBA/WNBA), threaded + // from the stats resolve above. Absent → PlayerAvatar renders a monogram. + playerId: playerIdByPlayer[pn] ?? g.playerId ?? null, + espnId: espnIdByPlayer[pn] ?? g.espnId ?? null, + }; + }); // Line deltas vs the previous snapshot's locked lines. const prev = await deps.cacheGet(`snapshot:${sp}:latest`); diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 3d504fc..86bc1ef 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -83,6 +83,16 @@ const MARKET_MAP = { // are shared with soccer/NBA — sport context discriminates downstream. player_shots_on_goal: 'shots_on_goal', goalie_saves: 'saves', + + // MMA / UFC (Wave 6 — combat intelligence). Game-level markets, NOT player + // props: h2h = moneyline (per fighter), totals = round total (over/under). + // Mapped here so combat odds don't silently normalize to zero when wired + // through the odds-api path (same silent-failure class as the MLB/NHL gaps). + // The combat surface parses these via combatAdapter.normalizeCombatOdds — + // normalizeProps (this file) is player-prop-shaped and skips them, which is + // correct: MMA carries no per-player point props on the free feed. + h2h: 'moneyline', + totals: 'round_total', }; function normalizeProps(eventsWithOdds) { diff --git a/tests/unit/billingDisplay.test.js b/tests/unit/billingDisplay.test.js new file mode 100644 index 0000000..94dbbc9 --- /dev/null +++ b/tests/unit/billingDisplay.test.js @@ -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'); + }); +}); diff --git a/tests/unit/bookWordmark.test.js b/tests/unit/bookWordmark.test.js new file mode 100644 index 0000000..416b74a --- /dev/null +++ b/tests/unit/bookWordmark.test.js @@ -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 ', (slug) => { + const svg = read(`web/public/books/${slug}.svg`); + expect(svg).toMatch(//); + }); + + 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* { + 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(' { + const src = read('web/src/app/u/[handle]/PublicProfile.tsx'); + expect(src).toContain('BookWordmark'); + expect(src).toContain(' { + 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({}); + }); +}); diff --git a/tests/unit/combatArchetypes.test.js b/tests/unit/combatArchetypes.test.js new file mode 100644 index 0000000..4bace21 --- /dev/null +++ b/tests/unit/combatArchetypes.test.js @@ -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/); + }); +}); diff --git a/tests/unit/combatFightCard.test.js b/tests/unit/combatFightCard.test.js new file mode 100644 index 0000000..bdfa4f0 --- /dev/null +++ b/tests/unit/combatFightCard.test.js @@ -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| { + 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/); + }); +}); diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js index 51ace72..6c2f934 100644 --- a/tests/unit/ds5PricingStates.test.js +++ b/tests/unit/ds5PricingStates.test.js @@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () => expect(pricing).not.toContain("originalPrice: '$49.99'"); }); - test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => { + test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => { expect(page).toContain('import DeskShowcase'); expect(page).toContain(' { expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ'); expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS'); }); + test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => { + // NBA abbrs the ESPN schedule emits that used to fall to a monogram + expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs + expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks + expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards + expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets + // WNBA + expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun + expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics + }); + test('global alias never shadows a real same-abbr team in another sport', () => { + // WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the + // NBA/WNBA WSH→WAS alias. + expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals'); + // NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias. + expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty'); + // NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed. + expect(resolveTeam('NY', 'mlb')).toBeNull(); + }); test('unknown team → null (never a fake)', () => { expect(resolveTeam('ZZZ', 'mlb')).toBeNull(); expect(resolveTeam('', 'mlb')).toBeNull(); diff --git a/tests/unit/espnStatsAdapter.test.js b/tests/unit/espnStatsAdapter.test.js index 7f234ce..a8dfea0 100644 --- a/tests/unit/espnStatsAdapter.test.js +++ b/tests/unit/espnStatsAdapter.test.js @@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => { expect(r.found).toBe(true); expect(r.team).toBe('DAL'); expect(r.classifierInput.ppg).toBe(33); + // Wave 2A — the REAL ESPN athlete id is surfaced (headshot CDN), not discarded. + expect(r.espnId).toBe('123'); + }); + + it('Wave 2A — a non-numeric uid degrades espnId to null (never fabricated)', async () => { + const http = { + get: async (url) => { + if (url.includes('/search')) return { data: { items: [{ uid: 's:40~l:46~a:999', displayName: 'X', team: {}, position: {} }] } }; + return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 10 }] }] } } } }; + }, + }; + const r = await espn.getSeasonAverages('X', 'nba', { http }); + expect(r.found).toBe(true); + expect(r.espnId).toBeNull(); }); it('degrades to found:false when ESPN errors', async () => { @@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => { it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => { const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', { nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } }, - espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) }, + espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 }, espnId: '3945274' }) }, }); expect(r.found).toBe(true); expect(r.team).toBe('DAL'); expect(r.classifierInput.ppg).toBe(33); + // Wave 2A — espnId surfaces through resolvePlayerStats → the snapshot grade. + expect(r.espnId).toBe('3945274'); }); it('found:false when both sources are empty', async () => { diff --git a/tests/unit/gradeShift.test.js b/tests/unit/gradeShift.test.js new file mode 100644 index 0000000..417fadd --- /dev/null +++ b/tests/unit/gradeShift.test.js @@ -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(' | null'); + expect(src).toContain('revisedFrom'); + }); +}); diff --git a/tests/unit/headshotThread.test.js b/tests/unit/headshotThread.test.js new file mode 100644 index 0000000..e76fd28 --- /dev/null +++ b/tests/unit/headshotThread.test.js @@ -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'); + }); +}); diff --git a/tests/unit/houseProfile.test.js b/tests/unit/houseProfile.test.js new file mode 100644 index 0000000..751cf13 --- /dev/null +++ b/tests/unit/houseProfile.test.js @@ -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'); + }); +}); diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 082e2db..672daf7 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -251,6 +251,25 @@ describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => { expect(agg.by_tier.B.settled).toBe(3); expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building }); + + test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => { + const sb = fakeSb(); + const rows = [ + ...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })), + ...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })), + ...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })), + ]; + sb._state.selectResults = [rows]; + sb._state.countResult = 0; + const agg = await ledger.getModelAggregate({ sb }); + // A+ is its own bucket — never merged into the first-letter A bucket. + expect(agg.by_tier['A+'].settled).toBe(5); + expect(agg.by_tier['A+'].hits).toBe(5); + // A + A- fold together by first letter (but NOT A+). + expect(agg.by_tier.A.settled).toBe(6); + expect(agg.by_tier.A.hits).toBe(4); + expect(agg.by_tier.A.misses).toBe(2); + }); }); // Session 61 — the odds index prefers a fully-priced book row (the null diff --git a/tests/unit/marketBreadth.test.js b/tests/unit/marketBreadth.test.js new file mode 100644 index 0000000..973b144 --- /dev/null +++ b/tests/unit/marketBreadth.test.js @@ -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/); + }); +}); diff --git a/tests/unit/mlbNamesakeResolve.test.js b/tests/unit/mlbNamesakeResolve.test.js new file mode 100644 index 0000000..546fd3d --- /dev/null +++ b/tests/unit/mlbNamesakeResolve.test.js @@ -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(); + }); +}); diff --git a/tests/unit/oddsNormalizer.test.js b/tests/unit/oddsNormalizer.test.js index 165613e..7c3d6ff 100644 --- a/tests/unit/oddsNormalizer.test.js +++ b/tests/unit/oddsNormalizer.test.js @@ -244,4 +244,14 @@ describe('oddsNormalizer', () => { expect(result[0].away_team).toBe('PHX'); }); }); + + // Wave 6 — combat (MMA) game-level markets. Without these MARKET_MAP keys, + // combat moneyline/round-total odds would silently normalize to zero (same + // silent-failure class as the MLB/NHL gaps closed earlier). + describe('MMA / combat market keys (Wave 6)', () => { + it('maps h2h → moneyline and totals → round_total', () => { + expect(MARKET_MAP.h2h).toBe('moneyline'); + expect(MARKET_MAP.totals).toBe('round_total'); + }); + }); }); diff --git a/tests/unit/outlook.test.js b/tests/unit/outlook.test.js new file mode 100644 index 0000000..c35be82 --- /dev/null +++ b/tests/unit/outlook.test.js @@ -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/); + }); +}); diff --git a/tests/unit/parlayLab.test.js b/tests/unit/parlayLab.test.js new file mode 100644 index 0000000..310ad7e --- /dev/null +++ b/tests/unit/parlayLab.test.js @@ -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); + }); +}); diff --git a/tests/unit/pitcherArsenalComponent.test.js b/tests/unit/pitcherArsenalComponent.test.js new file mode 100644 index 0000000..d2e2607 --- /dev/null +++ b/tests/unit/pitcherArsenalComponent.test.js @@ -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 + }); +}); diff --git a/tests/unit/savantAdapter.test.js b/tests/unit/savantAdapter.test.js new file mode 100644 index 0000000..8e82613 --- /dev/null +++ b/tests/unit/savantAdapter.test.js @@ -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(); + }); +}); diff --git a/tests/unit/snapshotService.test.js b/tests/unit/snapshotService.test.js index 2e0150f..9b3c1aa 100644 --- a/tests/unit/snapshotService.test.js +++ b/tests/unit/snapshotService.test.js @@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => { expect(cache.store['grades:mlb'].grades).toHaveLength(2); }); + it('Wave 2A — threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => { + const cache = memCache(); + const d = deps(cache); + // Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape). + d.resolveStats = async (player) => (player === 'Aaron Judge' + ? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 } + : { found: true, classifierInput: {}, espnId: 4433403 }); + await svc.runSnapshot('mlb', d); + const snap = cache.store['snapshot:mlb:latest']; + const judge = snap.grades.find((g) => g.player === 'Aaron Judge'); + const betts = snap.grades.find((g) => g.player === 'Mookie Betts'); + expect(judge.playerId).toBe(592450); + expect(betts.espnId).toBe(4433403); + // grades:{sport} inherits the same ids (GameCard/Explore read from it). + const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge'); + expect(g.playerId).toBe(592450); + }); + + it('Wave 2A — no resolved id → enriched grade carries null ids (monogram path)', async () => { + const cache = memCache(); + const d = deps(cache); + d.resolveStats = async () => ({ found: false }); // nothing resolves + await svc.runSnapshot('mlb', d); + const snap = cache.store['snapshot:mlb:latest']; + expect(snap.grades[0].playerId).toBeNull(); + expect(snap.grades[0].espnId).toBeNull(); + }); + it('rotates latest → previous and computes deltas on the second run', async () => { const cache = memCache(); let line = 1.5; diff --git a/tests/unit/sportMarkets.test.js b/tests/unit/sportMarkets.test.js index 5bdadbb..7210b8b 100644 --- a/tests/unit/sportMarkets.test.js +++ b/tests/unit/sportMarkets.test.js @@ -64,14 +64,23 @@ describe('SPORT_MARKETS — isolation', () => { expect(wc).not.toMatch(/batter_/); }); - test('every market list ends with `spreads`', () => { - for (const list of Object.values(SPORT_MARKETS)) { + test('every PLAYER-PROP market list ends with `spreads`', () => { + for (const [sport, list] of Object.entries(SPORT_MARKETS)) { + // Wave 6 — MMA is a GAME-level sport (h2h + round totals only); it has + // no player-prop `spreads` market and odds-api 422s if one is sent. + if (sport === 'mma') continue; // We don't require spreads to be the literal final segment, // only that it's present in the comma-separated list. expect(list.split(',')).toContain('spreads'); } }); + test('MMA market list is game-level (h2h + totals), no spreads/player props', () => { + expect(SPORT_MARKETS.mma).toBe('h2h,totals'); + expect(SPORT_MARKETS.mma).not.toMatch(/spreads/); + expect(SPORT_MARKETS.mma).not.toMatch(/player_/); + }); + test('SPORT_MARKETS is frozen at the top level', () => { expect(Object.isFrozen(SPORT_MARKETS)).toBe(true); }); diff --git a/tests/unit/tierRecord.test.js b/tests/unit/tierRecord.test.js new file mode 100644 index 0000000..a7d7469 --- /dev/null +++ b/tests/unit/tierRecord.test.js @@ -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(' { + const src = read('app/dashboard/page.tsx'); + expect(src).toMatch(/TierRecord/); + expect(src).toContain(' { + const src = read('app/ledger/page.tsx'); + expect(src).toContain('TierRecord'); + expect(src).toContain('bet365 diff --git a/web/public/books/betmgm.svg b/web/public/books/betmgm.svg new file mode 100644 index 0000000..211334d --- /dev/null +++ b/web/public/books/betmgm.svg @@ -0,0 +1 @@ +BetMGM diff --git a/web/public/books/betrivers.svg b/web/public/books/betrivers.svg new file mode 100644 index 0000000..0c1d4f9 --- /dev/null +++ b/web/public/books/betrivers.svg @@ -0,0 +1 @@ +BetRivers diff --git a/web/public/books/caesars.svg b/web/public/books/caesars.svg new file mode 100644 index 0000000..9c803b0 --- /dev/null +++ b/web/public/books/caesars.svg @@ -0,0 +1 @@ +Caesars diff --git a/web/public/books/draftkings.svg b/web/public/books/draftkings.svg new file mode 100644 index 0000000..ee3a8b4 --- /dev/null +++ b/web/public/books/draftkings.svg @@ -0,0 +1 @@ +DraftKings diff --git a/web/public/books/fanduel.svg b/web/public/books/fanduel.svg new file mode 100644 index 0000000..18bb053 --- /dev/null +++ b/web/public/books/fanduel.svg @@ -0,0 +1 @@ +FanDuel diff --git a/web/public/books/hardrockbet.svg b/web/public/books/hardrockbet.svg new file mode 100644 index 0000000..19d8f3a --- /dev/null +++ b/web/public/books/hardrockbet.svg @@ -0,0 +1 @@ +Hard Rock diff --git a/web/public/books/pinnacle.svg b/web/public/books/pinnacle.svg new file mode 100644 index 0000000..9bd04b2 --- /dev/null +++ b/web/public/books/pinnacle.svg @@ -0,0 +1 @@ +Pinnacle diff --git a/web/src/app/api/combat/[date]/route.ts b/web/src/app/api/combat/[date]/route.ts new file mode 100644 index 0000000..3d38883 --- /dev/null +++ b/web/src/app/api/combat/[date]/route.ts @@ -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' }); + } +} diff --git a/web/src/app/api/fight/[id]/route.ts b/web/src/app/api/fight/[id]/route.ts new file mode 100644 index 0000000..711bbc4 --- /dev/null +++ b/web/src/app/api/fight/[id]/route.ts @@ -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 }); + } +} diff --git a/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts new file mode 100644 index 0000000..54c9c25 --- /dev/null +++ b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts @@ -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 }); + } +} diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 9e2f603..d9682da 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard'; // below as intelligence layers on top of the raw odds. import Slate from '@/components/Slate'; // Session 55 — the self-learning loop's track record, live in the header. -import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; +import { AccuracyBadge, Skeleton, SkeletonList, TierRecord } from '@/components/vyndr'; // Session 57 (Phase 0) — honest per-sport empty-slate copy. import { emptyStateCopy } from '@/lib/emptyState'; // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. @@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule'; // varying signal, and fall back to yesterday's PROVEN A-tier receipts so first // paint ALWAYS proves the model (#1, #13, Part 6). import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter'; +// Wave 4A (Step 3) — OUTLOOK MODE: the "Today's games" empty branch is never a +// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule. +import { buildOutlook } from '@/lib/outlook'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { currentAccessToken } from '@/lib/authToken'; @@ -109,6 +112,98 @@ const SPORT_COLOR: Record = { 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(null); + const [tomorrow, setTomorrow] = useState(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 ( +
+
+

OUTLOOK

+

{title}

+

{body}

+
+ + {receipts.length > 0 && ( +
+
YESTERDAY · PROVEN
+
+ {receipts.map((r: OutlookReceiptView, i: number) => ( +
+
+ {String(r.sport).toUpperCase()} + +
+
{r.player}
+

+ {String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')} +

+

+ ✓ HIT{r.actual != null ? ` (${r.actual})` : ''} +

+
+ ))} +
+
+ )} + + {preview.length > 0 && ( +
+
TOMORROW · SCHEDULE — lines post on the day
+
+ {preview.map((g: OutlookGameView) => ( +
+ {g.away} @ {g.home} + {g.time && {formatTime(g.time)}} +
+ ))} +
+
+ )} + + +
+ ); +} + +// Render-only views of the buildOutlook output (the JS adapter has no TS types). +interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null } +interface OutlookGameView { id: string; away: string; home: string; time: string | null } + export default function DashboardPage() { const router = useRouter(); const { user, session, tier, scansRemaining, loading: authLoading } = useAuth(); @@ -306,6 +401,15 @@ export default function DashboardPage() { }} /> + {/* Wave 3 (Addition 2) — the model's record BY GRADE TIER. Self-fetches + /api/ledger/model (scoped to the selected sport) and fully self-hides + until a tier has a settled read — no empty header. The SAME shared + TierRecord that renders on /u and the ledger; the calibration + gradient is the proof higher grades win more. */} +
+ +
+ {/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on the varying signal (selectTopGrades: tier → confidence → edge) so the row isn't identical-weight noise; when tonight is empty we fall back to @@ -422,21 +526,9 @@ export default function DashboardPage() { {games === null ? ( ) : games.length === 0 ? ( - // Session 57 (Phase 0) — honest per-sport empty state (spec §6): - // an off-season sport says when it returns; in-season = off-day. -
-

NO SLATE

-

{emptyStateCopy(sport.toLowerCase()).title}

-

- {emptyStateCopy(sport.toLowerCase()).body} -

- -
+ // Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA. + // Yesterday's proven receipts + tomorrow's real schedule fill the grid. + ) : (
{games.map((g, idx) => ( diff --git a/web/src/app/fight/[id]/FightCardClient.tsx b/web/src/app/fight/[id]/FightCardClient.tsx new file mode 100644 index 0000000..466fe03 --- /dev/null +++ b/web/src/app/fight/[id]/FightCardClient.tsx @@ -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(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 ( +
+ LOADING THE CARD… +
+ ); + } + + if (state === 'empty' || !event) { + return ( + + ); + } + + const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null; + + return ( +
+
+
+ {[event.shortName, dateStr, event.venue].filter(Boolean).join(' · ') || 'UFC'} +
+

+ {event.name || 'Fight Card'} +

+
+ +
+ {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 ( + + ); + })} +
+
+ ); +} diff --git a/web/src/app/fight/[id]/page.tsx b/web/src/app/fight/[id]/page.tsx new file mode 100644 index 0000000..125100d --- /dev/null +++ b/web/src/app/fight/[id]/page.tsx @@ -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 { + 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 ; +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index aa810e9..9c73df8 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } /* Terminal's multi-column grid stacks on mobile. */ @media (max-width: 768px) { .terminal-grid { grid-template-columns: 1fr !important; } + /* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal + table keeps its own internal grid. */ + .parsenal-grid { grid-template-columns: 1fr !important; } } /* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */ diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 579bc8d..2c46a37 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; -import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; +import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** @@ -177,30 +178,6 @@ export default function LedgerPage() { ); } -const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F']; - -/** Session 60 (5.5) — per-tier calibration: the separation between tiers is - * the proof the grades mean something. A tier under n≥20 shows "building", - * never a small-sample percentage. Self-hides until ANY tier is ready. */ -function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { - const tiers = agg.by_tier || {}; - const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null); - if (!anyReady) return null; - return ( -
- {TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => { - const b = tiers[t]; - const ready = b.hit_pct != null; - return ( - - {t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`} - - ); - })} -
- ); -} - /** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat * distribution renders as one giant bar + six empty stubs — it reads as @@ -305,7 +282,20 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
)} - + {/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table, + identical here, on the dashboard, and on /u. */} + + {/* Wave 5A (D2) — the shareable house/model profile. Same record, a + public page you can hand a partner. */} +
+ + VIEW AS PUBLIC PAGE → + +
); } @@ -365,7 +355,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { {row.side} {row.line} {row.stat.replace(/_/g, ' ')}

- {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} + {row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {/* model_value is MODEL output — always labeled, never blended with market numbers. */} {row.model_value != null && · MODEL {row.model_value}}

diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index cbe7130..15bc9b3 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import Hero from '@/components/Hero'; @@ -85,8 +86,16 @@ export default function Home() { {/* Session 58 (work-order 1.4) — the public model record (proof-strip footer). Deferred-render: "RECORD BUILDING" until 20 settles, then the real hit% + beat-close%. Self-hides with no data. */} -
+
+ {/* Wave 5A (D2) — the shareable public model record (the house profile). */} + + VIEW PUBLIC RECORD → +
{/* Founder-seat scarcity meter (§12) */}
diff --git a/web/src/app/parlay/page.tsx b/web/src/app/parlay/page.tsx new file mode 100644 index 0000000..f6490dc --- /dev/null +++ b/web/src/app/parlay/page.tsx @@ -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("MLB"); + const [rawProps, setRawProps] = useState([]); + const [loading, setLoading] = useState(false); + const [q, setQ] = useState(""); + const [suggest, setSuggest] = useState([]); + + // 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(); + 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 ( +
+ {/* Header */} +
+

CORRELATION BUILDER

+

+ PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD +

+
+ + {/* Sport tabs */} +
+ {SPORTS.map((s) => ( + + ))} +
+ + {/* Search — independent of the slate; resolves via /api/players/search */} + 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 && ( +
+ {suggest.join(" · ")} +
+ )} + +
+ {/* LEFT — tonight's A/B reads (the leg source) */} +
+
+ + TONIGHT'S GRADED READS + TAP TO ADD +
+ + {loading ? ( +
Loading tonight's reads…
+ ) : filtered.length === 0 ? ( +
+ {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.`} +
+ ) : ( +
+ {filtered.map((p) => { + const active = hasLeg(p.key); + return ( + + ); + })} +
+ )} +
+ + {/* RIGHT — the PARLAY SLIP (reads combined/correlation/payout from context) */} +
+
+ PARLAY SLIP + {legs.length} LEG{legs.length === 1 ? "" : "S"} +
+ + {/* legs */} +
+ {legs.length === 0 ? ( +
+ No legs yet — tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other. +
+ ) : ( + legs.map((l) => ( +
+ {l.archetype && } + {l.player} + {statLabel(l.stat)} {l.direction === "under" ? "U" : "O"}{l.line} + + +
+ )) + )} +
+ + {/* leg-cap notice (tier-aware) */} + {legs.length >= maxLegs && ( +
+ {fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs — upgrade to Desk for 6."} +
+ )} + + {/* CAUTION · CORRELATION FLAG — surfaces parlayService's warning */} + {correlation?.warning && ( +
+
⚠ CAUTION · CORRELATION FLAG
+
{correlation.warning}
+
+ )} + + {/* combined / grade / stake */} +
+
+
CORRELATION
+
0.3 ? "var(--amber)" : "var(--g-a)" }}> + {legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "—"} +
+
+
+
GRADE
+ {legs.length >= 2 && combined ? : {grading ? "…" : "—"}} +
+
+
EST · $10
+ {legs.length < 2 ? ( + + ) : fullLab ? ( + {payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "—"} + ) : ( + + $38.50 + + + )} +
+
+ + {legs.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index 6db4cb6..9ebffb6 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ModelRecord from '@/components/vyndr/ModelRecord'; import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; +import PitcherArsenal from '@/components/vyndr/PitcherArsenal'; import { archetypeInfo } from '@/lib/archetypes'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; @@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
)} + {/* 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' && ( + + )} + {/* D. ACTIVE PROPS */} {p.activeProps?.length > 0 && ( <> diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx index 8f8cb0b..31c0b8f 100644 --- a/web/src/app/pricing/DeskShowcase.tsx +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -36,7 +36,7 @@ export default function DeskShowcase() { THE DESK · FLAGSHIP

- A $1M terminal.{' '} + Every grade, every alt line, live.{' '} $44.99 /mo.

@@ -83,6 +83,12 @@ export default function DeskShowcase() { + {/* 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. */}
diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 2ee04f5..a6bae28 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -36,7 +36,7 @@ export const metadata: Metadata = { export default function PricingPage() { return (
- {/* 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. */} diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index 2ec8da8..fbe3505 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { currentAccessToken } from '@/lib/authToken'; +import { classifyRenewal } from '@/lib/billingDisplay'; interface FullProfile { id: string; @@ -125,7 +126,7 @@ export default function ProfilePage() { {tier !== 'free' && (
- +
)} @@ -170,7 +171,7 @@ export default function ProfilePage() {

Cancellation scheduled. Access ends{' '} - {profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}. + {accessEndsLabel(profile.subscription_end)}.

)} @@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go ); } +// Renewal render is guarded (Wave 1 trust bug) — VYNDR tiers are monthly, so a +// `subscription_end` far in the future is a comped/seed value, not a renewal. +// classifyRenewal decides; we never print a raw date the cadence can't justify. +function renewalLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + if (r.kind === 'none') return 'No scheduled renewal'; + if (r.kind === 'lapsed') return 'Lapsed'; + return '—'; +} + +// The cancel-scheduled line only shows a real near date; anything else falls to +// the honest "at period end" (never a fabricated 2036 access-end). +function accessEndsLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + return 'at period end'; +} + function tierColor(tier: string): string { if (tier === 'desk') return 'var(--grade-a)'; if (tier === 'analyst') return 'var(--grade-b)'; diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 56e4244..61964c1 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -16,7 +16,8 @@ import { trackScanLimitHit, trackUpgradeClicked, } from '@/lib/analytics'; -import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import { type HeadshotSport } from '@/lib/playerHeadshot'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -119,6 +120,9 @@ export default function ScanPage() { const [playerQuery, setPlayerQuery] = useState(''); const [playerSuggestions, setPlayerSuggestions] = useState([]); const [selectedPlayer, setSelectedPlayer] = useState(''); + // 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(null); const [stat, setStat] = useState('points'); const [line, setLine] = useState(''); const [direction, setDirection] = useState<'over' | 'under'>('over'); @@ -306,6 +310,7 @@ export default function ScanPage() { setError(''); setPlayerQuery(''); setSelectedPlayer(''); + setSelectedPlayerId(null); setLine(''); }; @@ -442,7 +447,6 @@ export default function ScanPage() { }} > {tonightsPlayers.map((p) => { - const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport }); const selected = selectedPlayer === p.name; return (

- {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} + {row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.model_value != null && · MODEL {row.model_value}}

diff --git a/web/src/app/u/[handle]/opengraph-image.tsx b/web/src/app/u/[handle]/opengraph-image.tsx index 8b63f46..b067632 100644 --- a/web/src/app/u/[handle]/opengraph-image.tsx +++ b/web/src/app/u/[handle]/opengraph-image.tsx @@ -11,7 +11,7 @@ export const contentType = 'image/png'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; -async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> { +async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number; house: boolean } | null> { try { const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { headers: { Accept: 'application/json' }, @@ -21,7 +21,7 @@ async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; be const d = await r.json(); const agg = d && d.aggregate; if (!agg || agg.hit_pct == null) return null; - return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses }; + return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses, house: Boolean(d.house) }; } catch { return null; } @@ -49,10 +49,10 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
- {rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'} + {rec ? (rec.house ? 'VYNDR MODEL · PUBLIC RECORD' : 'CLV-VERIFIED RECORD · 30D') : 'PUBLIC LEDGER'}
- @{h} + {rec?.house ? 'VYNDR MODEL' : `@${h}`}
{rec ? (
diff --git a/web/src/app/u/[handle]/portrait/route.tsx b/web/src/app/u/[handle]/portrait/route.tsx new file mode 100644 index 0000000..ded4d1b --- /dev/null +++ b/web/src/app/u/[handle]/portrait/route.tsx @@ -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( + ( +
+
+
+
+ {eyebrow} +
+
+ {heading} +
+
+ + {rec ? ( +
+
+
{rec.hit_pct}%
+
HIT RATE · {rec.hits}-{rec.misses}
+
+ {rec.beat_close_pct != null && ( +
+
{rec.beat_close_pct}%
+
BEAT CLOSE
+
+ )} +
+ ) : ( +
+ CLV-verified record · every settled read · misses included +
+ )} + +
+
+ VYND + R +
+
+ NOTHING CURATED · NOTHING DELETED +
+
+
+ ), + { ...SIZE }, + ); +} diff --git a/web/src/components/HotListPanel.tsx b/web/src/components/HotListPanel.tsx index 2c52832..c36ce52 100644 --- a/web/src/components/HotListPanel.tsx +++ b/web/src/components/HotListPanel.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import type { HeadshotSport } from '@/lib/playerHeadshot'; import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; /** @@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit {visible.map((p) => (
#{p.rank} - {p.name} { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} + {/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM); + team-colored monogram otherwise. Never a gray silhouette. */} +
{p.name}{p.team ? ` · ${p.team}` : ''}
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = { flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28, color: 'var(--text-tertiary, #6A6A78)', }; -const avatarStyle: React.CSSProperties = { - borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto', -}; const playerName: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/web/src/components/ParlayTray.tsx b/web/src/components/ParlayTray.tsx deleted file mode 100644 index 1fa6390..0000000 --- a/web/src/components/ParlayTray.tsx +++ /dev/null @@ -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(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 ( -
- - - - {legs.length === 0 ? ( - - ) : ( -
    - {legs.map((l) => ( - removeLeg(l.id)} /> - ))} -
- )} - - {parlayResult && ( -
-

- PARLAY GRADE -

-
- -
- {parlayResult.correlation_flags.length > 0 && ( -
-

- CORRELATION WARNINGS -

- {parlayResult.correlation_flags.map((f, i) => ( -

- {f.detail} -

- ))} -
- )} -
- )} - - {legs.length > 0 && ( -
- - -
- )} - -
- ); -} - -function EmptyTrayCopy() { - return ( -
-

- NO LEGS YET -

-

- Read a prop, hit Add to Parlay, and we'll build the slip here. - We grade overall correlation and surface the legs that secretly fight each other. -

-
- ); -} - -function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) { - return ( -
  • -
    -
    {leg.player}
    -
    - {leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')} -
    -
    -
    - - -
    -
  • - ); -} diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index 6287e00..ec9e84a 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [ highlight: false, }, { - // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal" + // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing // story and the single primary CTA on the grid (color contract #9: never // two competing green CTAs). $44.99 regular, $34.99 for founders. id: 'desk', diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 2ab4b71..b3192f4 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation'; import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard'; import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import { PropRowProp, Tier } from '@/components/PropRow'; -import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter'; +import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter'; +// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no +// live games (and it's not a fetch failure) the grid shows REAL data — +// yesterday's proven receipts + tomorrow's date-pinned schedule. +import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook'; +// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model. +import { collectBreadth } from '@/lib/marketBreadth'; +import MarketBreadth from '@/components/vyndr/MarketBreadth'; // A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort. // Grades never change in-game; these marks are tracking, labeled as such. import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress'; @@ -165,7 +172,7 @@ interface StreakApiRow { interface StreaksResponse { streaks?: StreakApiRow[] } // Session 45 — pre-graded snapshot response (snapshot:{sport}:latest). -interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } +interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number } interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] } @@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) { ); } +// The /api/ledger/model settled-row shape (subset the receipts read). +interface ModelReceiptRow { + player_name?: string; player?: string; sport?: string; stat?: string; + line?: number; side?: string; grade?: string; + outcome?: string | null; actual_value?: number | null; clv_result?: string | null; +} +// buildHeroReceipts output (proven yesterday hit). +interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null } +// mapTomorrowPreview output. +interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null } + +/** + * Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in + * place of the old dead-end CTA: yesterday's PROVEN A-tier receipts + + * tomorrow's date-pinned schedule preview (both REAL, always-available data — + * never an invented line). The month-aware header ALWAYS shows, so the grid is + * never blank. Distinct from the network `fetchError` state. + */ +function OutlookSurface({ tab }: { tab: SlateTab }) { + const [settled, setSettled] = useState(null); + const [tomorrow, setTomorrow] = useState(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 ( +
    +
    +

    OUTLOOK

    +

    {title}

    +

    {body}

    +
    + + {receipts.length > 0 && ( +
    +
    YESTERDAY · PROVEN
    +
    + {receipts.map((r, i) => ( +
    +
    + {String(r.sport || '').toUpperCase()} + {r.grade} +
    +
    {r.player}
    +
    + {String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')} +
    +
    + ✓ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''} +
    +
    + ))} +
    +
    + )} + + {preview.length > 0 && ( +
    +
    + TOMORROW · SCHEDULE — lines post on the day +
    +
    + {preview.map((g) => ( +
    + {g.sport && {g.sport}} + {g.away} @ {g.home} + {g.time && {formatGameTime(g.time)}} +
    + ))} +
    +
    + )} +
    + ); +} + export interface SlateProps { initialTab?: SlateTab; tier?: Tier; @@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook )) as SlateGame[]; }, [filteredGames, gradeIndex, liveIndex]); + // Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that + // carries ≥2 book lines, compare the market's median line to the model's + // projection (signed by the graded side). collectBreadth drops <2-book props + // and ranks by |edge| — an empty result self-hides the strip. This is the + // REAL data behind the DeskShowcase "consensus vs model" claim. + const breadthItems = useMemo(() => { + const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = []; + for (const g of filteredGames) { + for (const p of g.props) { + if (!Array.isArray(p.books) || p.books.length < 2) continue; + const grade = (gradeIndex as Record)[gradeKey(p.player, p.stat_type)]; + const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null; + items.push({ + player: p.player, + stat: p.stat_type, + side: (grade && grade.direction) || p.direction || 'over', + line: p.line, + books: p.books, + modelValue: mv == null ? null : Number(mv), + }); + } + } + return collectBreadth(items, 6); + }, [filteredGames, gradeIndex]); + // Session 25 — per-sport game counts for the tab labels, derived from // the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the // free ESPN schedule even when odds are empty. Counts only appear for @@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
    )} - {!loading && !fetchError && filteredGames.length === 0 && ( + {/* A search miss keeps its own scan-it CTA. */} + {!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
    - {searchQuery ? ( - <> -

    - No props found for “{searchQuery}”. -

    - - Scan it manually → - - - ) : ( - // Session 57 (Phase 0) — honest per-sport empty copy (spec §6): - // off-season sports name their return window; in-season = off-day. - (() => { - const { title, body } = emptyStateCopy(tab); - return ( - <> -

    {title}

    -

    {body}

    - - ); - })() - )} +

    + No props found for “{searchQuery}”. +

    + + Scan it manually → + +
    + )} + + {/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA: + when there are no live games (and no search, no fetch failure) it shows + yesterday's proven receipts + tomorrow's real schedule. Yesterday/ + Tomorrow date nav keep the plain honest copy (those are explicit date + surfaces; -1 already has THE SETTLE below). */} + {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && ( + + )} + {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && ( +
    + {(() => { + const { title, body } = emptyStateCopy(tab); + return ( + <> +

    {title}

    +

    {body}

    + + ); + })()}
    )} {dateOffset === -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 && } +
    {orderedGames.map((g, i) => ( - + {s.glyphChar ? ( + // Combat glyphs are unicode chars (data never glitches — chrome label). + + {s.glyphChar} + + ) : ( + + )} {s.name} {showDesc && s.desc && ( diff --git a/web/src/components/vyndr/BookWordmark.tsx b/web/src/components/vyndr/BookWordmark.tsx index 04628b2..7c66c9f 100644 --- a/web/src/components/vyndr/BookWordmark.tsx +++ b/web/src/components/vyndr/BookWordmark.tsx @@ -1,10 +1,16 @@ -import { bookInfo } from '@/lib/books'; +import { bookInfo, bookSlug, hasBookSvg } from '@/lib/books'; /** - * BookWordmark (DS0) — a sportsbook renders as its brand: the real name in - * the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare - * lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts - * where the BookChip tile is too heavy. `best` gives it the signal ring. + * BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a + * bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8 + * major books) it renders that; otherwise it falls back to the real book NAME + * in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare + * lowercase "draftkings" string (DESIGN-SPEC Part 2). + * + * The bundled SVGs are self-authored styled text wordmarks — not copied + * trademarked logo glyphs — so the founder can drop official press-kit art into + * the same paths later with zero code change. The files are local + always + * present, so no broken-image state is possible. `best` gives the signal ring. */ export default function BookWordmark({ book, @@ -16,6 +22,8 @@ export default function BookWordmark({ size?: number; }) { const b = bookInfo(book); + const slug = bookSlug(book); + const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null; return ( {best && } - {b.name} + {svg ? ( + // eslint-disable-next-line @next/next/no-img-element + {b.name} + ) : ( + b.name + )} ); } diff --git a/web/src/components/vyndr/FightCard.tsx b/web/src/components/vyndr/FightCard.tsx new file mode 100644 index 0000000..e94aaa8 --- /dev/null +++ b/web/src/components/vyndr/FightCard.tsx @@ -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 ( + + {initials(name)} + + ); +} + +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 ( +
    +
    + +
    +
    + {f.name} +
    +
    + {meta.length ? meta.join(' · ') : `RECORD ${DASH}`} +
    +
    +
    + + {/* Style-blend bars (MODEL) — only when the fighter is profiled. */} + {bars.length > 0 ? ( +
    + {bars.map((b) => { + const c = combatArchetypeColor(b.archetype); + const pct = Math.round((b.weight || 0) * 100); + return ( +
    +
    + + {b.archetype} + + {pct}% +
    +
    +
    +
    +
    + ); + })} +
    + ) : ( +
    + STYLE PROFILE {DASH} DATA-LIMITED +
    + )} + + {primary && ( +
    + +
    + )} + + {/* Discipline pedigree tags — verifiable only, absent when unknown. */} + {Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && ( +
    + {f.pedigrees.map((p) => ( + + {p.toUpperCase()} + + ))} +
    + )} +
    + ); +} + +function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) { + return ( +
    +
    + {label} +
    + {dataLimited ? ( +
    {DASH} data-limited
    + ) : ( +
    {value}
    + )} +
    + ); +} + +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 ( +
    + {/* Card header — weight class + rounds (mono chrome). */} +
    +
    + + + {[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' · ') || 'BOUT'} + +
    + {status === 'post' && ( + FINAL + )} +
    + + {/* FIGHTER A · VERDICT · FIGHTER B */} +
    + + +
    +
    VERDICT
    +
    VS
    +
    + {v.verdict} +
    +
    MODEL READ
    +
    + + +
    + + {v.summary && ( +

    + {v.summary} +

    + )} + + {/* Odds row — MONEYLINE + round total REAL; method/round/KO data-limited. */} +
    + + + + +
    +

    + 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. +

    +
    + ); +} diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index ef68c2a..d182524 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@ import TeamLogo from '@/components/vyndr/TeamLogo'; import { accentColor } from '@/lib/teamMeta'; import { playerHref } from '@/lib/playerHref'; -import { isPreferredBook } from '@/lib/books'; +import { isPreferredBook, bookInfo } from '@/lib/books'; import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -37,6 +37,9 @@ export interface GameProp { export interface PlayerStrip { player: string; team: string; + // Wave 2A — real headshot ids threaded from the snapshot grade. + playerId?: string | number | null; + espnId?: string | number | null; archetype?: StripArchetype; // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). lineup?: { status: string; slot?: number } | null; @@ -301,7 +304,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
    O/U
    {g.lines.map((ln, i) => ( -
    {ln.book}
    +
    {bookInfo(ln.book).name}
    @@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks player={ps.player} team={ps.team} sport={g.sport} + playerId={ps.playerId} + espnId={ps.espnId} archetype={ps.archetype} lineup={ps.lineup} injury={ps.injury} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 9283c70..8d789e7 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -6,6 +6,9 @@ import SectionHead from '@/components/vyndr/SectionHead'; import VBtn from '@/components/vyndr/VBtn'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import GradeBadge from '@/components/vyndr/GradeBadge'; +import GradeShift from '@/components/vyndr/GradeShift'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import { type HeadshotSport } from '@/lib/playerHeadshot'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { playerHref } from '@/lib/playerHref'; @@ -14,6 +17,10 @@ export interface GradeResultData { player: string; team: string; sport: string; + // Wave 2A — real headshot ids (optional; self-hide to a team-colored + // monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId. + playerId?: string | number | null; + espnId?: string | number | null; stat: string; line: number; side: 'Over' | 'Under'; @@ -33,6 +40,12 @@ export interface GradeResultData { propDNA?: { reliable: string[]; volatile: string[] }; statContext?: { season?: string; last10?: string; vsOpp?: string }; vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string }; + // Wave 4B — LIVE GRADE-SHIFT timeline (all optional; GradeShift self-hides + // below 3 real captured points). Fed by the snapshot pipeline's already- + // emitted line history + public revision; the scan path leaves them absent. + history?: Array<{ t: string; line: number }> | null; + revisedFrom?: string | null; + gradedLine?: number | null; } interface GradeResultCardProps { @@ -96,12 +109,17 @@ export default function GradeResultCard({ {/* 1. HEADER — player name links to the full profile (Session 42) */}
    -
    +
    + {/* Wave 2A — player identity block: real headshot / team-colored + monogram (never a gray silhouette). */} + +
    {d.player}
    {d.side.toUpperCase()} {d.line} ·{d.stat}
    +
    @@ -187,6 +205,12 @@ export default function GradeResultCard({ ))}
    + {/* 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. */} + + {/* 5. SIGNAL BREAKDOWN */} {d.signals.length > 0 && (
    diff --git a/web/src/components/vyndr/GradeShift.tsx b/web/src/components/vyndr/GradeShift.tsx new file mode 100644 index 0000000..6abe937 --- /dev/null +++ b/web/src/components/vyndr/GradeShift.tsx @@ -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 | 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 ( +
    + {/* header + legend */} +
    + + GRADE HISTORY · LAST 24 + + + {tl.net.dir === "toward" ? "TOWARD" : tl.net.dir === "against" ? "AGAINST" : "FLAT"} {netSign}{tl.net.delta} + +
    + + {/* revision — original grade struck-through, never silently */} + {tl.revision && ( +
    + REVISED + {tl.revision.from} + + {tl.revision.to} +
    + )} + + {/* bars — one per capture, height by relative line, colored by segment dir */} +
    + {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 ( +
    0 ? "+" : ""}${p.delta})` : ""}`} + style={{ + flex: 1, + height: `${h}%`, + borderRadius: "3px 3px 0 0", + background: bg, + opacity: p.dir === "flat" ? 0.55 : 0.9, + }} + /> + ); + })} +
    + + {/* clock rail — lock -> now, mirrors the mockup's TIP/NOW */} +
    + + LOCK {tl.firstLine} + + + NOW {tl.lastLine} + +
    +
    + ); +} diff --git a/web/src/components/vyndr/MarketBreadth.tsx b/web/src/components/vyndr/MarketBreadth.tsx new file mode 100644 index 0000000..6c36c87 --- /dev/null +++ b/web/src/components/vyndr/MarketBreadth.tsx @@ -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 = { + 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 ( +
    + + {title} + + MEDIAN BOOK LINE · MODEL EDGE + + +
    + {rows.map((r, i) => { + const col = edgeColor(r.signedEdge); + const sideChar = r.side === 'under' ? 'u' : 'o'; + return ( +
    + {r.player && {r.player}} + + {statLabel(r.stat)} {sideChar}{r.line ?? r.consensus} + + + CONSENSUS {r.consensus} + · {r.bookCount} BOOKS + + {r.model != null ? ( + + MODEL {r.model} + {r.signedEdge != null && ( + · {fmtSigned(r.signedEdge)} + )} + + ) : ( + MODEL — + )} +
    + ); + })} +
    +
    + ); +} diff --git a/web/src/components/vyndr/PitcherArsenal.tsx b/web/src/components/vyndr/PitcherArsenal.tsx new file mode 100644 index 0000000..df61036 --- /dev/null +++ b/web/src/components/vyndr/PitcherArsenal.tsx @@ -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 = { + 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(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 = ( +
    +
    + {/* identity */} +
    +
    +
    {monogram}
    +
    +
    + {meta.name || 'Probable Pitcher'} + {meta.confirmed && ( + CONFIRMED + )} +
    +
    + {[meta.hand, meta.number != null ? `#${meta.number}` : null, meta.team, meta.vs ? `vs ${meta.vs}` : null].filter(Boolean).join(' · ') || 'ARSENAL'} +
    +
    +
    +
    +
    THE ARSENAL READ
    +

    + {(() => { + 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.'; + })()} +

    +
    +
    + + {/* arsenal table */} +
    +
    + PITCH + VELO + USE + WHIFF +
    + {pitches.map((p, i) => { + const sharp = isSharp(p); + return ( +
    + + + {p.name} + + {fmtVelo(p.velo)} + {fmtPct(p.usagePct)} + {fmtPct(p.whiffPct)} +
    + ); + })} +
    SOURCE · BASEBALL SAVANT (STATCAST)
    +
    +
    +
    + ); + + 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 ( +
    +
    + + PITCHER IDENTITY + ONE IDENTITY → MANY PROPS +
    + {Card} +
    + ); +} diff --git a/web/src/components/vyndr/PlayerAvatar.tsx b/web/src/components/vyndr/PlayerAvatar.tsx index e074cc0..7b56a49 100644 --- a/web/src/components/vyndr/PlayerAvatar.tsx +++ b/web/src/components/vyndr/PlayerAvatar.tsx @@ -21,17 +21,26 @@ export default function PlayerAvatar({ name, sport = 'mlb', playerId, + espnId, team, size = 36, }: { name: string; sport?: HeadshotSport; + /** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */ playerId?: string | number | null; + /** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */ + espnId?: string | number | null; team?: string | null; size?: number; }) { const [broken, setBroken] = useState(false); - const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null; + // Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by + // sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at + // all → null → team-colored monogram (never a gray silhouette). + const url = (playerId != null || espnId != null) + ? getHeadshotUrl({ sport, playerId, espnId }) + : null; const accent = (team && accentColor(team, String(sport))) || '#4A9EFF'; const showImg = url && url !== '/images/player-silhouette.svg' && !broken; diff --git a/web/src/components/vyndr/SearchModal.tsx b/web/src/components/vyndr/SearchModal.tsx index f93f128..fe97de2 100644 --- a/web/src/components/vyndr/SearchModal.tsx +++ b/web/src/components/vyndr/SearchModal.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { playerHref } from '@/lib/playerHref'; import { searchTeams, teamHref } from '@/lib/teams'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import { type HeadshotSport } from '@/lib/playerHeadshot'; /** * SearchModal — S6 (A1 board). Global search over players + teams. @@ -31,6 +33,8 @@ interface ResultItem { sub: string; // team / abbr context sport: string; href: string; + // Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic). + playerId?: string | number | null; } export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) { @@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose: const seen = new Set(); const merged: ResultItem[] = []; for (const res of responses) { - for (const p of res.players as Array<{ full_name?: string; team?: string }>) { + for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) { const name = String(p.full_name || '').trim(); if (!name) continue; const key = `${name.toLowerCase()}|${res.sport}`; if (seen.has(key)) continue; seen.add(key); + // MLB's search id is the real MLBAM id → headshot. Other sports' + // ids are synthetic → guarded out → monogram. + const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null; merged.push({ kind: 'player', label: name, sub: p.team ? String(p.team) : '', sport: res.sport, href: playerHref(name, res.sport), + playerId, }); if (merged.length >= 9) break; } @@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose: fontSize: 13, }} > - {/* ROW-GRAMMAR: never truncate a name — wrap instead. */} - {it.label} + {/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds + the player identity avatar (real headshot / branded monogram). */} + + {it.kind === 'player' && ( + + )} + {it.label} + {it.sub && {it.sub}} diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index b0bd6bd..0962409 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -195,6 +195,10 @@ interface StatStripProps { player: string; team: string; sport?: string; + // Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM + // playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram. + playerId?: string | number | null; + espnId?: string | number | null; archetype?: StripArchetype; // Session 64 (A1-S5) — viability (lineup confirmation + injury wire). lineup?: { status: string; slot?: number } | null; @@ -225,6 +229,8 @@ export default function StatStrip({ player, team, sport, + playerId, + espnId, archetype, lineup, injury, @@ -378,7 +384,7 @@ export default function StatStrip({
    {/* DS0 — player identity block: real headshot / team-colored monogram (never the gray silhouette). Team drives the accent color. */} - + {player} {team} {archetype && } diff --git a/web/src/components/vyndr/TierRecord.tsx b/web/src/components/vyndr/TierRecord.tsx new file mode 100644 index 0000000..a07de97 --- /dev/null +++ b/web/src/components/vyndr/TierRecord.tsx @@ -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 | 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; 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 ( +
    +
    + {title} · CALIBRATION +
    +
    + {rows.map((r) => ( +
    + {/* Tier label — green only for A-tier (the edge). */} + + {r.tier}-TIER + + {/* W-L record — ALWAYS shown, honest at any n. */} + + {r.record} + + {/* Hit-% ONLY past the n≥20 gate; else building copy. */} + {r.ready ? ( + + {r.hitPct}% HIT + + ) : ( + + RECORD BUILDING · {r.settled} settled + + )} +
    + ))} +
    +
    + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 082db2b..656ecde 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -9,6 +9,7 @@ export { default as Card } from './Card'; export { default as Sparkline } from './Sparkline'; export { default as Ticker } from './Ticker'; export { default as EmptyState } from './EmptyState'; +export { default as MarketBreadth } from './MarketBreadth'; export type { EmptyStateProps, EmptyStateAction } from './EmptyState'; export { default as GradeResultCard } from './GradeResultCard'; export type { GradeResultData } from './GradeResultCard'; @@ -18,13 +19,18 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f export { default as ClaimMeter } from './ClaimMeter'; export { default as AccuracyBadge } from './AccuracyBadge'; export { default as ModelRecord } from './ModelRecord'; +export { default as TierRecord } from './TierRecord'; /* Player Intelligence (Session 42) */ export { default as ArchetypeBadge } from './ArchetypeBadge'; +export { default as FightCard } from './FightCard'; +export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard'; export { default as ArchetypeBlend } from './ArchetypeBlend'; export type { BlendSegment } from './ArchetypeBlend'; export { default as StatStrip } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip'; +export { default as PitcherArsenal } from './PitcherArsenal'; +export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal'; export { default as BookChip } from './BookChip'; /* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */ diff --git a/web/src/config/sports.ts b/web/src/config/sports.ts index f03a5f5..0b2776a 100644 --- a/web/src/config/sports.ts +++ b/web/src/config/sports.ts @@ -22,7 +22,9 @@ export const SPORTS: Record = { nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, - mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, + // Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards + + // tale-of-the-tape). Not in the graded-props pipeline yet → collectData false. + mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, }; diff --git a/web/src/lib/archetypes.js b/web/src/lib/archetypes.js index 61bb7da..e615273 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -84,6 +84,21 @@ const ARCHETYPE_MAP = { WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, }; +/* Combat archetype visual map (Wave 6 — MMA/UFC). SEPARATE from ARCHETYPE_MAP: + FINISHER's combat name/color/glyph differ from the soccer FINISHER, and + combat FINISHER's green is intentionally close to the pitch-green here. Keys/ + colors/glyph CHARS MUST match src/services/archetypeService.js + COMBAT_ARCHETYPES — tests/unit/combatArchetypes.test.js asserts it. + `char` is a unicode glyph (rendered as text, not an SVG glyph key). */ +const COMBAT_ARCHETYPE_MAP = { + STRIKER: { c: '#E8703A', d: 'Wins on the feet — volume + power at range.', char: '✦', axis: 'range' }, + GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms — control + subs.', char: '⊗', axis: 'range' }, + PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➤', axis: 'tempo' }, + COUNTER: { c: '#8E7BE0', d: 'Patient — punishes what you show him.', char: '◊', axis: 'tempo' }, + FINISHER: { c: '#12B886', d: 'Ends nights — high KO/SUB rate.', char: '▲', axis: 'outcome' }, + GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: '▦', axis: 'outcome' }, +}; + const FALLBACK = { c: '#9499A8', d: '', g: '' }; // Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to @@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) { if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k; } -function archetypeInfo(name) { +function archetypeInfo(name, sport) { const key = (name == null ? '' : String(name)).toUpperCase(); + // Combat archetypes live in their own namespace (FINISHER collides with the + // soccer archetype) — resolve them ONLY when the sport is MMA. + if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) { + return COMBAT_ARCHETYPE_MAP[key]; + } if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key]; if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]]; return FALLBACK; } -function archetypeColor(name) { - return archetypeInfo(name).c; +function archetypeColor(name, sport) { + return archetypeInfo(name, sport).c; +} + +/** Combat-only color lookup (unambiguous — no soccer FINISHER collision). */ +function combatArchetypeColor(name) { + const key = (name == null ? '' : String(name)).toUpperCase(); + return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c; } function glyphSvg(glyphKey) { @@ -114,8 +140,8 @@ function glyphSvg(glyphKey) { * variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default). * size: 'sm' | 'md'. */ -function badgeStyle(name, variant = 'tint', size = 'sm') { - const info = archetypeInfo(name); +function badgeStyle(name, variant = 'tint', size = 'sm', sport) { + const info = archetypeInfo(name, sport); const sm = size === 'sm'; let textColor, bg, borderColor, glyphColor, textShadow = 'none'; if (variant === 'full' || variant === 'solid') { @@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') { } // Display the canonical VYNDR name even if a legacy name was passed. const upper = (name == null ? '' : String(name)).toUpperCase(); - const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; + const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper]; + const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; return { name: canonical, desc: info.d, glyph: info.g, + // Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys). + glyphChar: isCombat ? info.char : null, color: info.c, textColor, bg, borderColor, glyphColor, textShadow, fontSize: sm ? '9.5px' : '12px', @@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') { module.exports = { GLYPHS, ARCHETYPE_MAP, + COMBAT_ARCHETYPE_MAP, archetypeInfo, archetypeColor, + combatArchetypeColor, glyphSvg, badgeStyle, }; diff --git a/web/src/lib/billingDisplay.js b/web/src/lib/billingDisplay.js new file mode 100644 index 0000000..c0f8452 --- /dev/null +++ b/web/src/lib/billingDisplay.js @@ -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 }; diff --git a/web/src/lib/books.js b/web/src/lib/books.js index 48a42c7..4e5f2d1 100644 --- a/web/src/lib/books.js +++ b/web/src/lib/books.js @@ -1,29 +1,69 @@ /* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html - BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */ + BOOKS table. CommonJS so it's testable + importable from the .tsx chip. + + Wave 2B (data train) — the map now covers EVERY key the odds feed emits + (`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars, + fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book + falls to the neutral-gray default. Entries carry a `slug` = the canonical + lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and + BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under + `web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for + official press-kit art without a code change). */ const BOOKS = { - DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, - CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, 'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, - BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' }, + // BetRivers is a blue book (its logo is blue "BetRivers"), not purple. + BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, + BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, - FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, - B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' }, - HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' }, + PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' }, UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' }, }; +// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg). +// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs. +const BUNDLED_BOOK_SVGS = new Set([ + 'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers', +]); + +const DEFAULT_FG = '#B8BCC8'; + function bookInfo(book) { const key = String(book == null ? '' : book).toUpperCase(); - return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' }; + return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' }; +} + +/** Canonical lowercase book slug for any input (id/code/name), or null when the + * book isn't in the registry. Used to look up the bundled wordmark SVG. */ +function bookSlug(book) { + const info = bookInfo(book); + if (info.slug) return info.slug; + // Unknown-but-nameable: derive a slug from the resolved name (still not the + // raw lowercase feed key). Only bundled slugs matter for the SVG lookup. + return null; +} + +/** Does `book` have a bundled local wordmark SVG? */ +function hasBookSvg(book) { + const slug = bookSlug(book); + return slug != null && BUNDLED_BOOK_SVGS.has(slug); } /** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings" @@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) { return preferred.some((p) => bookKey(p) === k); } -module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook }; +module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook }; diff --git a/web/src/lib/gradeAdapter.js b/web/src/lib/gradeAdapter.js index f646f60..68957c0 100644 --- a/web/src/lib/gradeAdapter.js +++ b/web/src/lib/gradeAdapter.js @@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) { player: input.player || '', team: input.team || '', sport: (input.sport || 'nba').toString().toLowerCase(), + // Wave 2A — real headshot ids (optional; card self-hides to a monogram). + playerId: input.playerId != null ? input.playerId : null, + espnId: input.espnId != null ? input.espnId : null, stat: statLabel(input.stat), line, side, diff --git a/web/src/lib/gradeShift.js b/web/src/lib/gradeShift.js new file mode 100644 index 0000000..75436b3 --- /dev/null +++ b/web/src/lib/gradeShift.js @@ -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, +}; diff --git a/web/src/lib/marketBreadth.js b/web/src/lib/marketBreadth.js new file mode 100644 index 0000000..d392fa9 --- /dev/null +++ b/web/src/lib/marketBreadth.js @@ -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 }; diff --git a/web/src/lib/outlook.js b/web/src/lib/outlook.js new file mode 100644 index 0000000..2f2fa13 --- /dev/null +++ b/web/src/lib/outlook.js @@ -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 }; diff --git a/web/src/lib/playerHeadshot.ts b/web/src/lib/playerHeadshot.ts index f0b3e7e..2f25c05 100644 --- a/web/src/lib/playerHeadshot.ts +++ b/web/src/lib/playerHeadshot.ts @@ -1,82 +1,52 @@ /** - * Player headshot URL construction (Session 19). + * Player headshot URL construction (Session 19; Wave 2A refactor). * - * Each league hosts its own CDN; we don't proxy through ESPN as the - * primary because (a) ESPN rate-limits image hotlinking and (b) the - * league CDNs are the same sources PrizePicks and Sleeper use, so - * coverage is closer to 100%. + * The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's + * requireable by the plain-JS Jest suite AND importable here (allowJs) — same + * single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This + * file adds the TypeScript surface (types + the `headshotFromPlayer` helper). + * + * Each league hosts its own CDN; we don't proxy through ESPN as the primary + * because (a) ESPN rate-limits image hotlinking and (b) the league CDNs are the + * same sources PrizePicks and Sleeper use, so coverage is closer to 100%. * * Fallback chain inside the resolver: - * 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer, - * where API-Football returns the photo in player responses), use - * that directly. We DO NOT construct soccer URLs because no - * central CDN exists for player headshots. + * 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct + * soccer URLs (no central CDN for player headshots). * 2. League CDN with `playerId` — official source. - * 3. ESPN CDN fallback — only when `espnId` is present and no - * league ID is available. Helpful while the roster table is - * being backfilled with league-specific IDs. - * 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette. - * - * `` in the consuming component handles 404s from the - * CDN (e.g. a player who's in our roster but the league hasn't - * uploaded a headshot yet) by swapping to the silhouette. + * 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots. + * 4. `/images/player-silhouette.svg` — the sentinel the consuming component + * swaps for a team-colored MONOGRAM (never a gray blob, never a broken + * image). `` also degrades a 404 to the monogram. */ +import { + getHeadshotUrl as coreGetHeadshotUrl, + PLAYER_SILHOUETTE as CORE_SILHOUETTE, +} from '@/lib/playerHeadshotUrl'; + export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string; export interface HeadshotInput { sport: HeadshotSport; /** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */ playerId?: string | number | null; - /** Optional ESPN ID as a fallback when no league ID is known. */ + /** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */ espnId?: string | number | null; /** Pre-cached photo URL (used by soccer where each league has no central CDN). */ cachedPhotoUrl?: string | null; } -export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg'; - -const ESPN_SPORT_PATH: Record = { - nba: 'nba', - wnba: 'wnba', - mlb: 'mlb', - // ESPN soccer headshots are inconsistent across leagues — explicitly - // omit so soccer falls through to silhouette unless a cached photo - // is provided. -}; +export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE; export function getHeadshotUrl(input: HeadshotInput): string { - const sport = String(input.sport || '').toLowerCase(); - const playerId = input.playerId != null ? String(input.playerId) : ''; - const espnId = input.espnId != null ? String(input.espnId) : ''; - const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : ''; - - if (cached) return cached; - - if (playerId) { - switch (sport) { - case 'nba': - return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`; - case 'wnba': - return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`; - case 'mlb': - return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`; - default: - break; - } - } - - if (espnId && ESPN_SPORT_PATH[sport]) { - return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`; - } - - return PLAYER_SILHOUETTE; + return coreGetHeadshotUrl(input); } /** - * Convenience wrapper for the common case where the caller has a - * player object with mixed ID fields. Pulls the first non-empty ID - * out of the union before delegating to `getHeadshotUrl`. + * Convenience wrapper for the common case where the caller has a player object + * with mixed ID fields. Pulls the first non-empty ID out of the union before + * delegating to `getHeadshotUrl`. */ export function headshotFromPlayer(player: { sport?: string; diff --git a/web/src/lib/playerHeadshotUrl.js b/web/src/lib/playerHeadshotUrl.js new file mode 100644 index 0000000..138a3d7 --- /dev/null +++ b/web/src/lib/playerHeadshotUrl.js @@ -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 }; diff --git a/web/src/lib/routes.js b/web/src/lib/routes.js index a9f3228..dc1a5f3 100644 --- a/web/src/lib/routes.js +++ b/web/src/lib/routes.js @@ -35,6 +35,10 @@ const OPEN_ROUTES = [ '/dashboard', '/slate', '/scan', + /* Wave 4B — the Parlay Lab is the parlay-building funnel: anon/free reach it + (free 2-leg cap + payout-blur upsell), same monetization logic as scan + + dashboard, so it stays OPEN, not gated. */ + '/parlay', '/compare', '/game', '/pricing', diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 5f466c6..82c1b20 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat player: displayName(p.player), team: knownTeam, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, + // Wave 2A — real headshot id threaded from the snapshot grade + // (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram. + playerId: (rec && rec.playerId != null ? rec.playerId : undefined), + espnId: (rec && rec.espnId != null ? rec.espnId : undefined), lineup: lineupStatusFor(pk, knownTeam), injury: injuryFor(pk), stats: [], @@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat const cand = displayName(p.player); if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand; if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype }; + // Fill an id from a later graded row if the first row lacked one. + if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId; + if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId; } if (rec) { const side = sideCh(rec.direction); @@ -596,6 +603,7 @@ module.exports = { isRelevantGame, indexGrades, indexDeltas, + gradeKey, statShort, gradedAgo, buildPlayerStripsFromProps, diff --git a/web/src/lib/teamMeta.js b/web/src/lib/teamMeta.js index bc230ed..27cd0bb 100644 --- a/web/src/lib/teamMeta.js +++ b/web/src/lib/teamMeta.js @@ -195,7 +195,16 @@ function resolveTeam(key, sport) { const up = String(key).toUpperCase().trim(); if (table[up]) return { abbr: up, sport: sp, ...table[up] }; // statsapi/ESPN alias fallbacks for the two MLB mismatches - const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' }; + // Global alias table. resolveTeam checks the real abbr FIRST, so an entry + // only fires for a sport whose table lacks that key — e.g. WSH resolves + // directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to + // WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit + // (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here. + const ALIAS = { + ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', + GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX', + SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON', + }; if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] }; if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) { const a = SOCCER_ALIAS[normKey(key)]; diff --git a/web/src/lib/tierRecord.js b/web/src/lib/tierRecord.js new file mode 100644 index 0000000..4e848f5 --- /dev/null +++ b/web/src/lib/tierRecord.js @@ -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, +}; diff --git a/web/src/lib/vyndrTokens.js b/web/src/lib/vyndrTokens.js index a506bee..d4b8a29 100644 --- a/web/src/lib/vyndrTokens.js +++ b/web/src/lib/vyndrTokens.js @@ -25,6 +25,9 @@ const SPORT = { mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' }, wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' }, soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' }, + // Wave 6 — combat: the #D4AF37 championship-gold token (matches + // src/services/shareCards/tokens.js + config/sports.js mma color). + mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' }, }; /* GradeBadge size variants — hero stays 80–120px (§5: grade letter is