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
+84
View File
@@ -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/);
});
});