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:
@@ -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 };
|
||||
Reference in New Issue
Block a user