From e311f53738b1925a35a487597901f02dcd3161dd Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 22 Jul 2026 11:21:29 -0400 Subject: [PATCH] Read-card scan: honest no-market empty state + surface real priced lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Session-78 diagnosis stands: the join works, and a marketless scan rightly shows no triplet. This makes that absence legible and points the user at what IS priced, without fabricating a market. PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a number we can't stand behind") as the honesty precedent, and a designed EmptyState component whose actions give a path forward — so the empty state is built in the established visual language, not freelanced. Reachability: the scanner already fetches games/odds/search per selection; the snapshot is one more public, 30s-cached fetch per sport, re-run when the sport changes. Staleness: the snapshot rotates 5x/day and every scan re-validates the market server-side at submit time, so a surfaced line that goes stale degrades to the empty state on tap rather than a vanishing triplet — the stale-tap guard is inherent, not bolted on. Reversibility was the design constraint. The working card, price triplet, grade adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none of them even reference the new empty state. Everything new lives in two added files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive blocks in the scan page. Removing them leaves the Scan-A path untouched. Non-fabricating by construction: indexPricedLines only keeps snapshot rows that carry a real book price, keyed by exact player+stat via nameKey. A different stat priced for the same player surfaces nothing for the picked stat; an off-slate player surfaces nothing; nothing is suggested, interpolated, or rounded to a nearest line. The empty state shows no market numbers of its own — only real priced lines as one-tap chips, or a link to the live board when there are none. Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the free-typed line input, and the scanner still accepts any player, stat and line. Tapping a chip pre-fills the priced line and re-scans it — the market is re-resolved server-side, so the tap either yields a real triplet or degrades to the honest empty state. Path forward, not a wall: a marketless scan no longer dead-ends in blank space. It states truthfully that the board didn't price that line, keeps the grade, and routes the user to the priced lines for that exact player+stat or to tonight's board. Tests 3760 passed / 303 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- tests/unit/pricedLines.test.js | 84 ++++++++++++++ web/src/app/scan/page.tsx | 79 ++++++++++++++ web/src/components/vyndr/NoMarketState.tsx | 121 +++++++++++++++++++++ web/src/lib/pricedLines.js | 54 +++++++++ 4 files changed, 338 insertions(+) create mode 100644 tests/unit/pricedLines.test.js create mode 100644 web/src/components/vyndr/NoMarketState.tsx create mode 100644 web/src/lib/pricedLines.js diff --git a/tests/unit/pricedLines.test.js b/tests/unit/pricedLines.test.js new file mode 100644 index 0000000..2e16a31 --- /dev/null +++ b/tests/unit/pricedLines.test.js @@ -0,0 +1,84 @@ +/* ============================================================ + Session 79 — priced-line surfacer + no-market empty state. + NON-FABRICATING: only real snapshot lines with a real book price surface. + ============================================================ */ + +const { indexPricedLines, pricedLinesFor } = require('../../web/src/lib/pricedLines'); + +const GRADES = [ + { player_name: 'Gabriel Arias', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 }, + { player_name: 'Ryan Jeffers', stat_type: 'hits', line: 1.5, direction: 'under', book_odds: -160, fair_odds: -135 }, + { player_name: 'Chase DeLauter', stat_type: 'doubles', line: 0.5, direction: 'over', book_odds: 340, fair_odds: 370 }, + // no market → must NOT surface + { player_name: 'No Market Guy', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: null }, +]; + +describe('indexPricedLines — only real, priced rows', () => { + it('indexes a priced row by player+stat', () => { + const idx = indexPricedLines(GRADES); + const rows = pricedLinesFor(idx, 'Gabriel Arias', 'hits'); + expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165 }]); + }); + + it('EXCLUDES a graded row with no book price — not a surfaceable line', () => { + const idx = indexPricedLines(GRADES); + expect(pricedLinesFor(idx, 'No Market Guy', 'hits')).toEqual([]); + }); + + it('a DIFFERENT stat priced for the same player returns nothing for the picked stat', () => { + const idx = indexPricedLines(GRADES); + // DeLauter has doubles priced, NOT hits. + expect(pricedLinesFor(idx, 'Chase DeLauter', 'hits')).toEqual([]); + expect(pricedLinesFor(idx, 'Chase DeLauter', 'doubles')).toHaveLength(1); + }); + + it('an off-slate player returns nothing — never a nearest/suggested line', () => { + const idx = indexPricedLines(GRADES); + expect(pricedLinesFor(idx, 'Aaron Judge', 'hits')).toEqual([]); + }); + + it('resolves name variants via nameKey (A.J. / AJ)', () => { + const idx = indexPricedLines([{ player_name: 'A.J. Ewing', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110 }]); + expect(pricedLinesFor(idx, 'AJ Ewing', 'hits')).toHaveLength(1); + }); + + it('sorts multiple priced lines by line', () => { + const idx = indexPricedLines([ + { player_name: 'X', stat_type: 'hits', line: 1.5, direction: 'over', book_odds: 120 }, + { player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 }, + ]); + expect(pricedLinesFor(idx, 'X', 'hits').map((r) => r.line)).toEqual([0.5, 1.5]); + }); + + it('never fabricates on empty/garbage input', () => { + expect(pricedLinesFor(indexPricedLines([]), 'X', 'hits')).toEqual([]); + expect(pricedLinesFor(indexPricedLines(null), 'X', 'hits')).toEqual([]); + expect(pricedLinesFor(null, 'X', 'hits')).toEqual([]); + }); + + it('dedupes a repeated line+side', () => { + const idx = indexPricedLines([ + { player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 }, + { player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 }, + ]); + expect(pricedLinesFor(idx, 'X', 'hits')).toHaveLength(1); + }); +}); + +describe('the empty state + surfacer are independently reversible', () => { + const fs = require('fs'); + it('the working card/join/triplet are untouched (Scan A byte-identical)', () => { + // These files must NOT reference the new empty-state — proving the new UI is + // additive and removable without touching the working path. + const card = fs.readFileSync(require.resolve('../../web/src/components/vyndr/GradeResultCard.tsx'), 'utf8'); + const route = fs.readFileSync(require.resolve('../../web/src/app/api/scan/route.ts'), 'utf8'); + expect(card).not.toMatch(/NoMarketState/); + expect(route).not.toMatch(/NoMarketState|pricedLines/); + }); + + it('the empty state lives entirely in the scan page + its own component', () => { + const page = fs.readFileSync(require.resolve('../../web/src/app/scan/page.tsx'), 'utf8'); + expect(page).toMatch(/NoMarketState/); + expect(page).toMatch(/result\.book_odds == null \|\| result\.fair_odds == null/); + }); +}); diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index b3a2137..5f153e5 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -17,6 +17,8 @@ import { trackUpgradeClicked, } from '@/lib/analytics'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import NoMarketState, { type PricedLine } from '@/components/vyndr/NoMarketState'; +import { indexPricedLines, pricedLinesFor } from '@/lib/pricedLines'; import { type HeadshotSport } from '@/lib/playerHeadshot'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; @@ -136,6 +138,12 @@ export default function ScanPage() { const [direction, setDirection] = useState<'over' | 'under'>('over'); const [scanning, setScanning] = useState(false); const [result, setResult] = useState(null); + // Session 79 — the CURRENT snapshot's priced lines, indexed by player+stat, so + // a marketless scan can surface REAL priced lines (never suggested/nearest). + const [pricedIndex, setPricedIndex] = useState | null>(null); + // One-tap re-scan of a surfaced priced line: bump this to re-run runScan AFTER + // line/direction state has committed. + const [rescanKey, setRescanKey] = useState(0); const [error, setError] = useState(''); // Session 19 — tonight's players grid. Pulled from the odds proxy // (props array) so the chip set is real, not hard-coded. Each entry @@ -148,6 +156,20 @@ export default function ScanPage() { if (!authLoading && !user) router.replace('/signup?next=/scan'); }, [authLoading, user, router]); + // Session 79 — the REAL priced lines for the EXACT selected player+stat. Empty + // for a player/stat the board didn't price — never suggested or interpolated. + const pricedForSelection = useMemo( + () => (pricedIndex && selectedPlayer && stat ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []), + [pricedIndex, selectedPlayer, stat], + ); + + // One-tap re-scan after a surfaced priced line commits its line/direction. + useEffect(() => { + if (rescanKey === 0) return; + void runScan(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rescanKey]); + // Reset stat selection when sport changes useEffect(() => { const list = SPORT_STATS[sport]; @@ -170,6 +192,22 @@ export default function ScanPage() { }; }, [sport]); + // Session 79 — index the current snapshot's priced lines for the surfacer. + // /api/snapshot is public + cached 30s and is re-validated server-side at + // scan time, so a surfaced line that goes stale degrades to the honest empty + // state on tap rather than a vanishing triplet. + useEffect(() => { + let cancelled = false; + setPricedIndex(null); + fetch(`/api/snapshot/${sport.toLowerCase()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data: { grades?: unknown[] } | null) => { + if (!cancelled) setPricedIndex(indexPricedLines((data && data.grades) || [])); + }) + .catch(() => !cancelled && setPricedIndex(new Map())); + return () => { cancelled = true; }; + }, [sport]); + // Session 19 — fetch tonight's players from the odds proxy. The // odds endpoint returns the canonical list of players who have // props posted, which is exactly what the scan UI should surface @@ -604,6 +642,27 @@ export default function ScanPage() { value={line} onChange={(e) => setLine(e.target.value)} /> + {/* Session 79 — HELP, not restriction: the board's REAL priced lines + for this exact player+stat. Tap to pre-fill a line that will yield + a real triplet. The scanner still accepts any free-typed line. */} + {pricedForSelection.length > 0 && ( +
+ + PRICED TONIGHT: + + {pricedForSelection.map((l, i) => ( + + ))} +
+ )}
@@ -775,6 +834,26 @@ export default function ScanPage() { onReadAnother={reset} /> + {/* Session 79 — HONEST EMPTY STATE. A marketless scan (the board never + priced this exact player+stat+line) renders no triplet on the card + above; this says so truthfully and surfaces the REAL priced lines + (one-tap to a genuine triplet), or points at the live board. The + working card/join/triplet is byte-identical — this is a separate + block that only appears when book_odds/fair_odds came back null. */} + {(result.book_odds == null || result.fair_odds == null) && ( + { + setLine(String(l.line)); + setDirection(l.direction); + setRescanKey((k) => k + 1); + }} + /> + )} + {/* Session 60 (4.3) — the model's public history on this player. Deferred-render: shows only when real ledger rows exist. */} diff --git a/web/src/components/vyndr/NoMarketState.tsx b/web/src/components/vyndr/NoMarketState.tsx new file mode 100644 index 0000000..d3c1422 --- /dev/null +++ b/web/src/components/vyndr/NoMarketState.tsx @@ -0,0 +1,121 @@ +'use client'; + +/** + * NoMarketState (Session 79) — the honest empty state for a scanned prop the + * board never priced. + * + * A manual scan is an arbitrary player + stat + free-typed line, so most scans + * land on a prop no book line was captured for. The join correctly returns no + * market and the read card's price triplet correctly self-hides — but a blank + * space reads as "the product is empty." This says, truthfully, what happened + * and points the user at what IS priced. + * + * TRUTH LAW: NO market numbers, no placeholder odds, no "coming soon." The only + * numbers shown are REAL priced lines from the current snapshot (passed in), + * each a one-tap route to a genuine triplet. If there are none, it degrades to + * copy + a path to the live board. Everything renders from tokens. + * + * FRAMING: this is HELP ("here's what we price tonight"), not RESTRICTION — the + * scanner still accepts any prop; this only appears once a scan came back + * marketless. + */ + +export interface PricedLine { + line: number; + direction: 'over' | 'under'; + book_odds: number; +} + +const fmtOdds = (o: number) => (o > 0 ? `+${Math.round(o)}` : String(Math.round(o))); + +export default function NoMarketState({ + player, + stat, + line, + pricedLines = [], + onPick, +}: { + player: string; + stat: string; + line: number | string; + /** REAL priced lines for this exact player+stat from the current snapshot. */ + pricedLines?: PricedLine[]; + /** Tap a real priced line → re-scan it (the market is re-validated server-side). */ + onPick?: (l: PricedLine) => void; +}) { + const statLabel = String(stat || '').replace(/_/g, ' '); + return ( +
+
+ NO LIVE MARKET FOR THIS LINE +
+

+ The board hasn’t priced {player}{' '} + {statLabel} at {String(line)}. The grade + above stands; there’s just no book number to price it against. +

+ + {pricedLines.length > 0 ? ( + <> +
+ PRICED TONIGHT · TAP TO READ THE TRIPLET +
+
+ {pricedLines.map((l, i) => ( + + ))} +
+ + ) : ( + + SEE TONIGHT’S PRICED BOARD ▸ + + )} +
+ ); +} diff --git a/web/src/lib/pricedLines.js b/web/src/lib/pricedLines.js new file mode 100644 index 0000000..7dbe2c6 --- /dev/null +++ b/web/src/lib/pricedLines.js @@ -0,0 +1,54 @@ +/* ============================================================ + pricedLines (Session 79) — the REAL priced lines from the current snapshot, + indexed for the scanner to surface. Plain CommonJS so it's unit-testable and + importable by the scan page (allowJs). + + NON-FABRICATING BY CONSTRUCTION: it only ever returns lines that are actually + in the snapshot with a real book price. Nothing is suggested, interpolated, + or rounded to a nearest. A player+stat the board didn't price returns []. + ============================================================ */ + +const { nameKey } = require('./playerName'); + +/** + * indexPricedLines(grades) — snapshot grades → Map(playerKey|stat → [rows]). + * A row is kept ONLY if it carries a real book price — a graded prop with no + * market is not a priceable line to surface. + */ +function indexPricedLines(grades) { + const idx = new Map(); + for (const g of Array.isArray(grades) ? grades : []) { + if (!g) continue; + const book = g.book_odds; + if (book == null || book === '') continue; // no market → not surfaceable + const player = g.player_name || g.player; + const stat = String(g.stat_type || g.stat || '').toLowerCase(); + if (!player || !stat) continue; + const line = Number(g.line); + if (!Number.isFinite(line)) continue; + const key = `${nameKey(player)}|${stat}`; + const side = String(g.direction || g.side || 'over').toLowerCase() === 'under' ? 'under' : 'over'; + const rows = idx.get(key) || []; + // Dedupe on line+side — one snapshot shouldn't carry the same line twice, + // but be defensive so the chip list never repeats. + if (!rows.some((r) => r.line === line && r.direction === side)) { + rows.push({ line, direction: side, book_odds: Number(book) }); + } + idx.set(key, rows); + } + return idx; +} + +/** + * pricedLinesFor(index, player, stat) — the priced lines for an EXACT + * player+stat. A different stat priced for the same player returns nothing for + * the picked stat; an off-slate player returns nothing. Sorted by line. + */ +function pricedLinesFor(index, player, stat) { + if (!index || !player || !stat) return []; + const key = `${nameKey(player)}|${String(stat).toLowerCase()}`; + const rows = index.get(key) || []; + return [...rows].sort((a, b) => a.line - b.line); +} + +module.exports = { indexPricedLines, pricedLinesFor };