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,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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
trackUpgradeClicked,
|
trackUpgradeClicked,
|
||||||
} from '@/lib/analytics';
|
} from '@/lib/analytics';
|
||||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
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 { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
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 [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [result, setResult] = useState<ScanResponse | null>(null);
|
const [result, setResult] = useState<ScanResponse | null>(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<Map<string, PricedLine[]> | 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('');
|
const [error, setError] = useState('');
|
||||||
// Session 19 — tonight's players grid. Pulled from the odds proxy
|
// Session 19 — tonight's players grid. Pulled from the odds proxy
|
||||||
// (props array) so the chip set is real, not hard-coded. Each entry
|
// (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');
|
if (!authLoading && !user) router.replace('/signup?next=/scan');
|
||||||
}, [authLoading, user, router]);
|
}, [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
|
// Reset stat selection when sport changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = SPORT_STATS[sport];
|
const list = SPORT_STATS[sport];
|
||||||
@@ -170,6 +192,22 @@ export default function ScanPage() {
|
|||||||
};
|
};
|
||||||
}, [sport]);
|
}, [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
|
// Session 19 — fetch tonight's players from the odds proxy. The
|
||||||
// odds endpoint returns the canonical list of players who have
|
// odds endpoint returns the canonical list of players who have
|
||||||
// props posted, which is exactly what the scan UI should surface
|
// props posted, which is exactly what the scan UI should surface
|
||||||
@@ -604,6 +642,27 @@ export default function ScanPage() {
|
|||||||
value={line}
|
value={line}
|
||||||
onChange={(e) => setLine(e.target.value)}
|
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 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: 'var(--text-3)', letterSpacing: '0.1em', alignSelf: 'center' }}>
|
||||||
|
PRICED TONIGHT:
|
||||||
|
</span>
|
||||||
|
{pricedForSelection.map((l, i) => (
|
||||||
|
<button
|
||||||
|
key={`${l.direction}-${l.line}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setLine(String(l.line)); setDirection(l.direction); }}
|
||||||
|
className="mono"
|
||||||
|
style={{ cursor: 'pointer', fontSize: 11, fontWeight: 700, padding: '4px 9px', borderRadius: 999, border: '1px solid var(--border-hi)', background: 'transparent', color: 'var(--text-1)', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
{l.direction === 'under' ? 'U' : 'O'} {l.line}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mono" style={labelStyle}>Side</label>
|
<label className="mono" style={labelStyle}>Side</label>
|
||||||
@@ -775,6 +834,26 @@ export default function ScanPage() {
|
|||||||
onReadAnother={reset}
|
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) && (
|
||||||
|
<NoMarketState
|
||||||
|
player={selectedPlayer}
|
||||||
|
stat={stat}
|
||||||
|
line={line}
|
||||||
|
pricedLines={pricedForSelection}
|
||||||
|
onPick={(l) => {
|
||||||
|
setLine(String(l.line));
|
||||||
|
setDirection(l.direction);
|
||||||
|
setRescanKey((k) => k + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session 60 (4.3) — the model's public history on this player.
|
{/* Session 60 (4.3) — the model's public history on this player.
|
||||||
Deferred-render: shows only when real ledger rows exist. */}
|
Deferred-render: shows only when real ledger rows exist. */}
|
||||||
<PriorReads player={selectedPlayer} stat={stat} />
|
<PriorReads player={selectedPlayer} stat={stat} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="no-market-state"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '16px 18px',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber)', marginBottom: 6 }}
|
||||||
|
>
|
||||||
|
NO LIVE MARKET FOR THIS LINE
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-1)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||||
|
The board hasn’t priced <span className="mono" style={{ color: 'var(--text-0)' }}>{player}</span>{' '}
|
||||||
|
{statLabel} at <span className="mono" style={{ color: 'var(--text-0)' }}>{String(line)}</span>. The grade
|
||||||
|
above stands; there’s just no book number to price it against.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{pricedLines.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-3)', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
PRICED TONIGHT · TAP TO READ THE TRIPLET
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{pricedLines.map((l, i) => (
|
||||||
|
<button
|
||||||
|
key={`${l.direction}-${l.line}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick && onPick(l)}
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: '7px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--fair-border)',
|
||||||
|
background: 'var(--fair-tint)',
|
||||||
|
color: 'var(--text-0)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.direction === 'under' ? 'U' : 'O'} {l.line}{' '}
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>· {fmtOdds(l.book_odds)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--g-a)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SEE TONIGHT’S PRICED BOARD ▸
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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