Read-card scan: honest no-market empty state + surface real priced lines

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-22 11:21:29 -04:00
parent 4c9707ffbb
commit e311f53738
4 changed files with 338 additions and 0 deletions
+54
View File
@@ -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 };